From b291093482f3d0f9d6aad08eb8316dd311df5f6b Mon Sep 17 00:00:00 2001 From: David East Date: Wed, 29 Apr 2026 10:05:06 -0400 Subject: [PATCH 1/5] feat: typed responses, auth documentation, and pipeline improvements --- .agents/skills/stitch-sdk-usage/SKILL.md | 14 +- packages/sdk/README.md | 11 +- packages/sdk/generated/domain-map.json | 5 + packages/sdk/generated/src/designsystem.ts | 16 +- packages/sdk/generated/src/index.ts | 6 +- packages/sdk/generated/src/project.ts | 27 +- .../sdk/generated/src/responses.generated.ts | 115 +++++++++ packages/sdk/generated/src/screen.ts | 18 +- packages/sdk/generated/src/stitch.ts | 12 +- .../sdk/generated/src/tool-definitions.ts | 4 +- packages/sdk/generated/src/types.generated.ts | 242 ++++++++++++++++++ packages/sdk/generated/stitch-sdk.lock | 12 +- packages/sdk/package.json | 5 +- packages/sdk/src/client.ts | 8 +- packages/sdk/src/index.ts | 3 + packages/sdk/src/upload-handler.ts | 12 + packages/sdk/src/utils.ts | 29 +++ packages/sdk/src/version.ts | 2 +- packages/sdk/test/integration/live.test.ts | 15 +- .../sdk/test/unit/parse-resource-name.test.ts | 29 +++ packages/sdk/test/unit/screen-factory.test.ts | 67 +++++ scripts/generate-sdk.ts | 183 ++++++++++++- scripts/test/generate-sdk.test.ts | 145 ++++++++++- scripts/tool-schema.ts | 1 + 24 files changed, 911 insertions(+), 70 deletions(-) create mode 100644 packages/sdk/generated/src/responses.generated.ts create mode 100644 packages/sdk/generated/src/types.generated.ts create mode 100644 packages/sdk/src/utils.ts create mode 100644 packages/sdk/test/unit/parse-resource-name.test.ts create mode 100644 packages/sdk/test/unit/screen-factory.test.ts diff --git a/.agents/skills/stitch-sdk-usage/SKILL.md b/.agents/skills/stitch-sdk-usage/SKILL.md index 359ca4d..c713d75 100644 --- a/.agents/skills/stitch-sdk-usage/SKILL.md +++ b/.agents/skills/stitch-sdk-usage/SKILL.md @@ -134,14 +134,18 @@ Both methods use cached data from the generation response when available, fallin For agents and orchestration scripts that forward JSON payloads to MCP tools: ```typescript -import { StitchToolClient } from '@google/stitch-sdk'; +import { stitch } from '@google/stitch-sdk'; -const client = new StitchToolClient(); // reads STITCH_API_KEY from env -const tools = await client.listTools(); -const result = await client.callTool("generate_screen_from_text", { +// Find available tools +const { tools } = await stitch.listTools(); +for (const tool of tools) { + console.log(`${tool.name}: ${tool.description}`); +} +// Call a tool with a JSON payload +const result = await stitch.callTool("generate_screen_from_text", { projectId: "123", prompt: "A login page" }); -await client.close(); +await stitch.close(); ``` ## Error Handling diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 06bc3b3..45871aa 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -100,15 +100,18 @@ for (const tool of tools) { console.log(tool.name, tool.description); } -// Call a tool directly -const result = await client.callTool("create_project", { +// Call a tool directly — returns are now strongly typed! +import { CreateProjectResponse } from "@google/stitch-sdk"; +const result = await client.callTool("create_project", { title: "Agent Project", }); +console.log(result.project?.projectId); await client.close(); ``` The client auto-connects on the first `callTool` or `listTools` call. No explicit `connect()` needed. +All tool responses and input parameters are strictly typed and exported from the SDK. ## API Reference @@ -165,8 +168,10 @@ A generated UI screen. Provides access to HTML and screenshots. Low-level authenticated pipe to the Stitch MCP server. Use this when you need direct tool access (e.g., in an AI agent). ```ts +import { StitchToolClient, GetScreenResponse } from "@google/stitch-sdk"; + const client = new StitchToolClient({ apiKey: "..." }); -const result = await client.callTool("tool_name", { arg: "value" }); +const result = await client.callTool("get_screen", { projectId: "...", screenId: "..." }); await client.close(); ``` diff --git a/packages/sdk/generated/domain-map.json b/packages/sdk/generated/domain-map.json index f09708c..773d439 100644 --- a/packages/sdk/generated/domain-map.json +++ b/packages/sdk/generated/domain-map.json @@ -27,6 +27,11 @@ "method": "designSystem", "returns": "DesignSystem", "description": "Create a DesignSystem handle from an existing ID without an API call." + }, + { + "method": "screen", + "returns": "Screen", + "description": "Create a Screen handle from an existing ID without an API call." } ], "sideEffects": [ diff --git a/packages/sdk/generated/src/designsystem.ts b/packages/sdk/generated/src/designsystem.ts index b2ce9bd..efe23e6 100644 --- a/packages/sdk/generated/src/designsystem.ts +++ b/packages/sdk/generated/src/designsystem.ts @@ -3,11 +3,13 @@ DO NOT EDIT — changes will be overwritten. Source: tools-manifest.json (sha256:2f1a623ec115...) - domain-map.json (sha256:baa17d36f4c1...) -Generated: 2026-04-28T16:31:23.449Z + domain-map.json (sha256:ffa082d8fbe7...) +Generated: 2026-04-28T20:49:35.251Z */ import { type StitchToolClient } from "../../src/client.js"; import { StitchError } from "../../src/spec/errors.js"; +import { DesignTheme, File, ProjectMetadata, ScreenInstance, Typography, UserFeedback, ProjectInput, ScreenInput, Asset, BoundingBox, ComponentRegion, Design, DesignSuggestion, DesignSystemInput, ProgressUpdate, ProgressUpdates, PrototypeLink, PrototypeLinks, PrototypeState, PrototypeV2Spec, ScreenMetadata, SessionOutputComponent, VariantOptions, SelectedScreenInstance } from "./types.generated.js"; +import { UpdateDesignSystemResponse, ApplyDesignSystemResponse } from "./responses.generated.js"; import { Screen } from "./screen.js"; /** Represents a visual theme or branding applied to projects and screens. */ @@ -36,9 +38,9 @@ export class DesignSystem { * Updates a design system for a project. Use this tool when the user wants to change the overall visual theme, style, or branding of the application. * Tool: update_design_system */ - async update(designSystem: any): Promise { + async update(designSystem: DesignSystemInput): Promise { try { - const raw = await this.client.callTool("update_design_system", { name: `assets/${this.assetId}`, projectId: this.projectId, designSystem }); + const raw = await this.client.callTool("update_design_system", { name: `assets/${this.assetId}`, projectId: this.projectId, designSystem }); return new DesignSystem(this.client, { ...raw, projectId: this.projectId }); } catch (error) { throw StitchError.fromUnknown(error); @@ -49,10 +51,10 @@ export class DesignSystem { * Applies a design system to a list of screens. Use this tool when the user wants to update one or more screens to match the style of a design system. * Tool: apply_design_system */ - async apply(selectedScreenInstances: any[]): Promise { + async apply(selectedScreenInstances: SelectedScreenInstance[]): Promise { try { - const raw = await this.client.callTool("apply_design_system", { assetId: this.assetId, projectId: this.projectId, selectedScreenInstances }); - return ((raw.outputComponents || []).flatMap((a: any) => a?.design?.screens || []) || []).map((item: any) => new Screen(this.client, { ...item, projectId: this.projectId })); + const raw = await this.client.callTool("apply_design_system", { assetId: this.assetId, projectId: this.projectId, selectedScreenInstances }); + return ((raw.outputComponents || []).flatMap((a: any) => a?.design?.screens || []) || []).map((item) => new Screen(this.client, { ...item, projectId: this.projectId })); } catch (error) { throw StitchError.fromUnknown(error); } diff --git a/packages/sdk/generated/src/index.ts b/packages/sdk/generated/src/index.ts index ac4029d..8ae39c1 100644 --- a/packages/sdk/generated/src/index.ts +++ b/packages/sdk/generated/src/index.ts @@ -3,11 +3,13 @@ DO NOT EDIT — changes will be overwritten. Source: tools-manifest.json (sha256:2f1a623ec115...) - domain-map.json (sha256:baa17d36f4c1...) -Generated: 2026-04-28T16:31:23.449Z + domain-map.json (sha256:ffa082d8fbe7...) +Generated: 2026-04-28T20:49:35.251Z */ export { Stitch } from "./stitch.js"; export { Project } from "./project.js"; export { Screen } from "./screen.js"; export { DesignSystem } from "./designsystem.js"; export { toolDefinitions, type ToolDefinition, type ToolInputSchema, type ToolPropertySchema } from "./tool-definitions.js"; +export type * from "./types.generated.js"; +export type * from "./responses.generated.js"; diff --git a/packages/sdk/generated/src/project.ts b/packages/sdk/generated/src/project.ts index 5fd20b9..38cedd2 100644 --- a/packages/sdk/generated/src/project.ts +++ b/packages/sdk/generated/src/project.ts @@ -3,11 +3,13 @@ DO NOT EDIT — changes will be overwritten. Source: tools-manifest.json (sha256:2f1a623ec115...) - domain-map.json (sha256:baa17d36f4c1...) -Generated: 2026-04-28T16:31:23.449Z + domain-map.json (sha256:ffa082d8fbe7...) +Generated: 2026-04-28T20:49:35.251Z */ import { type StitchToolClient } from "../../src/client.js"; import { StitchError } from "../../src/spec/errors.js"; +import { DesignTheme, File, ProjectMetadata, ScreenInstance, Typography, UserFeedback, ProjectInput, ScreenInput, Asset, BoundingBox, ComponentRegion, Design, DesignSuggestion, DesignSystemInput, ProgressUpdate, ProgressUpdates, PrototypeLink, PrototypeLinks, PrototypeState, PrototypeV2Spec, ScreenMetadata, SessionOutputComponent, VariantOptions, SelectedScreenInstance } from "./types.generated.js"; +import { GenerateScreenFromTextResponse, ListScreensResponse, GetScreenResponse, CreateDesignSystemResponse, ListDesignSystemsResponse } from "./responses.generated.js"; import { Screen } from "./screen.js"; import { DesignSystem } from "./designsystem.js"; @@ -37,7 +39,7 @@ export class Project { */ async generate(prompt: string, deviceType?: "DEVICE_TYPE_UNSPECIFIED" | "MOBILE" | "DESKTOP" | "TABLET" | "AGNOSTIC", modelId?: "MODEL_ID_UNSPECIFIED" | "GEMINI_3_PRO" | "GEMINI_3_FLASH" | "GEMINI_3_1_PRO"): Promise { try { - const raw = await this.client.callTool("generate_screen_from_text", { projectId: this.projectId, prompt, deviceType, modelId }); + const raw = await this.client.callTool("generate_screen_from_text", { projectId: this.projectId, prompt, deviceType, modelId }); const _projected = (raw?.outputComponents ?? []).find((c: any) => c?.design?.screens != null)?.design?.screens?.[0]; if (!_projected) throw new StitchError({ code: "UNKNOWN_ERROR", message: "Incomplete API response from generate_screen_from_text: expected object at projection path", recoverable: false }); return new Screen(this.client, { ..._projected, projectId: this.projectId }) @@ -52,8 +54,8 @@ export class Project { */ async screens(): Promise { try { - const raw = await this.client.callTool("list_screens", { projectId: this.projectId }); - return (raw?.screens || []).map((item: any) => new Screen(this.client, { ...item, projectId: this.projectId })); + const raw = await this.client.callTool("list_screens", { projectId: this.projectId }); + return (raw?.screens || []).map((item) => new Screen(this.client, { ...item, projectId: this.projectId })); } catch (error) { throw StitchError.fromUnknown(error); } @@ -65,7 +67,7 @@ export class Project { */ async getScreen(screenId: string): Promise { try { - const raw = await this.client.callTool("get_screen", { projectId: this.projectId, screenId, name: `projects/${this.projectId}/screens/${screenId}` }); + const raw = await this.client.callTool("get_screen", { projectId: this.projectId, screenId, name: `projects/${this.projectId}/screens/${screenId}` }); return new Screen(this.client, { ...raw, projectId: this.projectId }); } catch (error) { throw StitchError.fromUnknown(error); @@ -76,9 +78,9 @@ export class Project { * Creates a new design system for a project. Use this tool when the user wants to set or update the overall visual theme, style, or branding of the application. * Tool: create_design_system */ - async createDesignSystem(designSystem: any): Promise { + async createDesignSystem(designSystem: DesignSystemInput): Promise { try { - const raw = await this.client.callTool("create_design_system", { projectId: this.projectId, designSystem }); + const raw = await this.client.callTool("create_design_system", { projectId: this.projectId, designSystem }); return new DesignSystem(this.client, { ...raw, projectId: this.projectId }); } catch (error) { throw StitchError.fromUnknown(error); @@ -91,8 +93,8 @@ export class Project { */ async listDesignSystems(): Promise { try { - const raw = await this.client.callTool("list_design_systems", { projectId: this.projectId }); - return (raw?.designSystems || []).map((item: any) => new DesignSystem(this.client, { ...item, projectId: this.projectId })); + const raw = await this.client.callTool("list_design_systems", { projectId: this.projectId }); + return (raw?.designSystems || []).map((item) => new DesignSystem(this.client, { ...item, projectId: this.projectId })); } catch (error) { throw StitchError.fromUnknown(error); } @@ -102,4 +104,9 @@ export class Project { designSystem(id: string): DesignSystem { return new DesignSystem(this.client, { name: id, projectId: this.projectId }); } + + /** Create a Screen handle from an existing ID without an API call. */ + screen(id: string): Screen { + return new Screen(this.client, { id: id, projectId: this.projectId }); + } } diff --git a/packages/sdk/generated/src/responses.generated.ts b/packages/sdk/generated/src/responses.generated.ts new file mode 100644 index 0000000..302f8e8 --- /dev/null +++ b/packages/sdk/generated/src/responses.generated.ts @@ -0,0 +1,115 @@ +import { DesignTheme, File, ProjectMetadata, ScreenInstance, Typography, UserFeedback, ProjectInput, ScreenInput, Asset, BoundingBox, ComponentRegion, Design, DesignSuggestion, DesignSystemInput, ProgressUpdate, ProgressUpdates, PrototypeLink, PrototypeLinks, PrototypeState, PrototypeV2Spec, ScreenMetadata, SessionOutputComponent, VariantOptions, SelectedScreenInstance } from "./types.generated.js"; +/** + * AUTO-GENERATED by scripts/generate-sdk.ts +DO NOT EDIT — changes will be overwritten. + +Source: tools-manifest.json (sha256:2f1a623ec115...) + domain-map.json (sha256:ffa082d8fbe7...) +Generated: 2026-04-28T20:49:35.251Z + */ + +/** Response message for create_project. */ +export interface CreateProjectResponse { + backgroundTheme?: string; + createTime?: string; + designTheme?: DesignTheme; + deviceType?: "DEVICE_TYPE_UNSPECIFIED" | "MOBILE" | "DESKTOP" | "TABLET" | "AGNOSTIC"; + metadata?: ProjectMetadata; + name?: string; + origin?: "ORIGIN_UNSPECIFIED" | "STITCH" | "IMPORTED_FROM_GALILEO"; + projectType?: "PROJECT_TYPE_UNSPECIFIED" | "TEXT_TO_UI" | "TEXT_TO_UI_PRO" | "TEXT_TO_UI_PRO_IMAGE_SPACE" | "IMAGE_TO_UI" | "IMAGE_TO_UI_PRO" | "PROJECT_DESIGN"; + readTime?: string; + screenInstances?: ScreenInstance[]; + thumbnailScreenshot?: File; + title?: string; + updateTime?: string; + visibility?: "VISIBILITY_UNSPECIFIED" | "PUBLIC" | "PRIVATE"; +} + +/** Response message for get_project. */ +export interface GetProjectResponse { + backgroundTheme?: string; + createTime?: string; + designTheme?: DesignTheme; + deviceType?: "DEVICE_TYPE_UNSPECIFIED" | "MOBILE" | "DESKTOP" | "TABLET" | "AGNOSTIC"; + metadata?: ProjectMetadata; + name?: string; + origin?: "ORIGIN_UNSPECIFIED" | "STITCH" | "IMPORTED_FROM_GALILEO"; + projectType?: "PROJECT_TYPE_UNSPECIFIED" | "TEXT_TO_UI" | "TEXT_TO_UI_PRO" | "TEXT_TO_UI_PRO_IMAGE_SPACE" | "IMAGE_TO_UI" | "IMAGE_TO_UI_PRO" | "PROJECT_DESIGN"; + readTime?: string; + screenInstances?: ScreenInstance[]; + thumbnailScreenshot?: File; + title?: string; + updateTime?: string; + visibility?: "VISIBILITY_UNSPECIFIED" | "PUBLIC" | "PRIVATE"; +} + +/** Response message for list_projects. */ +export interface ListProjectsResponse { + projects?: ProjectInput[]; +} + +/** Response message for list_screens. */ +export interface ListScreensResponse { + screens?: ScreenInput[]; +} + +/** Response message for get_screen. */ +export interface GetScreenResponse { + deviceType?: "DEVICE_TYPE_UNSPECIFIED" | "MOBILE" | "DESKTOP" | "TABLET" | "AGNOSTIC"; + height?: string; + htmlCode?: File; + name?: string; + screenshot?: File; + title?: string; + width?: string; +} + +/** Response message for generate_screen_from_text. */ +export interface GenerateScreenFromTextResponse { + outputComponents?: SessionOutputComponent[]; + projectId?: string; + sessionId?: string; +} + +/** Response message for edit_screens. */ +export interface EditScreensResponse { + outputComponents?: SessionOutputComponent[]; + projectId?: string; + sessionId?: string; +} + +/** Response message for generate_variants. */ +export interface GenerateVariantsResponse { + outputComponents?: SessionOutputComponent[]; + projectId?: string; + sessionId?: string; +} + +/** Response message for create_design_system. */ +export interface CreateDesignSystemResponse { + copiedFrom?: string; + designSystem?: DesignSystemInput; + name?: string; + version?: string; +} + +/** Response message for update_design_system. */ +export interface UpdateDesignSystemResponse { + copiedFrom?: string; + designSystem?: DesignSystemInput; + name?: string; + version?: string; +} + +/** Response message for list_design_systems. */ +export interface ListDesignSystemsResponse { + designSystems?: Asset[]; +} + +/** Response message for apply_design_system. */ +export interface ApplyDesignSystemResponse { + outputComponents?: SessionOutputComponent[]; + projectId?: string; + sessionId?: string; +} diff --git a/packages/sdk/generated/src/screen.ts b/packages/sdk/generated/src/screen.ts index d352cc4..03323bf 100644 --- a/packages/sdk/generated/src/screen.ts +++ b/packages/sdk/generated/src/screen.ts @@ -3,11 +3,13 @@ DO NOT EDIT — changes will be overwritten. Source: tools-manifest.json (sha256:2f1a623ec115...) - domain-map.json (sha256:baa17d36f4c1...) -Generated: 2026-04-28T16:31:23.449Z + domain-map.json (sha256:ffa082d8fbe7...) +Generated: 2026-04-28T20:49:35.251Z */ import { type StitchToolClient } from "../../src/client.js"; import { StitchError } from "../../src/spec/errors.js"; +import { DesignTheme, File, ProjectMetadata, ScreenInstance, Typography, UserFeedback, ProjectInput, ScreenInput, Asset, BoundingBox, ComponentRegion, Design, DesignSuggestion, DesignSystemInput, ProgressUpdate, ProgressUpdates, PrototypeLink, PrototypeLinks, PrototypeState, PrototypeV2Spec, ScreenMetadata, SessionOutputComponent, VariantOptions, SelectedScreenInstance } from "./types.generated.js"; +import { EditScreensResponse, GenerateVariantsResponse, GetScreenResponse } from "./responses.generated.js"; /** A generated UI screen. Provides access to HTML and screenshots. */ export class Screen { @@ -37,7 +39,7 @@ export class Screen { */ async edit(prompt: string, deviceType?: "DEVICE_TYPE_UNSPECIFIED" | "MOBILE" | "DESKTOP" | "TABLET" | "AGNOSTIC", modelId?: "MODEL_ID_UNSPECIFIED" | "GEMINI_3_PRO" | "GEMINI_3_FLASH" | "GEMINI_3_1_PRO"): Promise { try { - const raw = await this.client.callTool("edit_screens", { projectId: this.projectId, selectedScreenIds: [this.screenId], prompt, deviceType, modelId }); + const raw = await this.client.callTool("edit_screens", { projectId: this.projectId, selectedScreenIds: [this.screenId], prompt, deviceType, modelId }); const _projected = (raw?.outputComponents ?? []).find((c: any) => c?.design?.screens != null)?.design?.screens?.[0]; if (!_projected) throw new StitchError({ code: "UNKNOWN_ERROR", message: "Incomplete API response from edit_screens: expected object at projection path", recoverable: false }); return new Screen(this.client, { ..._projected, projectId: this.projectId }) @@ -50,10 +52,10 @@ export class Screen { * Generates variants of existing screens within a project using a text prompt. * Tool: generate_variants */ - async variants(prompt: string, variantOptions: any, deviceType?: "DEVICE_TYPE_UNSPECIFIED" | "MOBILE" | "DESKTOP" | "TABLET" | "AGNOSTIC", modelId?: "MODEL_ID_UNSPECIFIED" | "GEMINI_3_PRO" | "GEMINI_3_FLASH" | "GEMINI_3_1_PRO"): Promise { + async variants(prompt: string, variantOptions: VariantOptions, deviceType?: "DEVICE_TYPE_UNSPECIFIED" | "MOBILE" | "DESKTOP" | "TABLET" | "AGNOSTIC", modelId?: "MODEL_ID_UNSPECIFIED" | "GEMINI_3_PRO" | "GEMINI_3_FLASH" | "GEMINI_3_1_PRO"): Promise { try { - const raw = await this.client.callTool("generate_variants", { projectId: this.projectId, selectedScreenIds: [this.screenId], prompt, variantOptions, deviceType, modelId }); - return ((raw.outputComponents || []).flatMap((a: any) => a?.design?.screens || []) || []).map((item: any) => new Screen(this.client, { ...item, projectId: this.projectId })); + const raw = await this.client.callTool("generate_variants", { projectId: this.projectId, selectedScreenIds: [this.screenId], prompt, variantOptions, deviceType, modelId }); + return ((raw.outputComponents || []).flatMap((a: any) => a?.design?.screens || []) || []).map((item) => new Screen(this.client, { ...item, projectId: this.projectId })); } catch (error) { throw StitchError.fromUnknown(error); } @@ -68,7 +70,7 @@ export class Screen { if (this.data?.htmlCode?.downloadUrl) return this.data?.htmlCode?.downloadUrl; try { - const raw = await this.client.callTool("get_screen", { projectId: this.projectId, screenId: this.screenId, name: `projects/${this.projectId}/screens/${this.screenId}` }); + const raw = await this.client.callTool("get_screen", { projectId: this.projectId, screenId: this.screenId, name: `projects/${this.projectId}/screens/${this.screenId}` }); return raw?.htmlCode?.downloadUrl || ""; } catch (error) { throw StitchError.fromUnknown(error); @@ -84,7 +86,7 @@ export class Screen { if (this.data?.screenshot?.downloadUrl) return this.data?.screenshot?.downloadUrl; try { - const raw = await this.client.callTool("get_screen", { projectId: this.projectId, screenId: this.screenId, name: `projects/${this.projectId}/screens/${this.screenId}` }); + const raw = await this.client.callTool("get_screen", { projectId: this.projectId, screenId: this.screenId, name: `projects/${this.projectId}/screens/${this.screenId}` }); return raw?.screenshot?.downloadUrl || ""; } catch (error) { throw StitchError.fromUnknown(error); diff --git a/packages/sdk/generated/src/stitch.ts b/packages/sdk/generated/src/stitch.ts index d1babfa..e94c781 100644 --- a/packages/sdk/generated/src/stitch.ts +++ b/packages/sdk/generated/src/stitch.ts @@ -3,11 +3,13 @@ DO NOT EDIT — changes will be overwritten. Source: tools-manifest.json (sha256:2f1a623ec115...) - domain-map.json (sha256:baa17d36f4c1...) -Generated: 2026-04-28T16:31:23.449Z + domain-map.json (sha256:ffa082d8fbe7...) +Generated: 2026-04-28T20:49:35.251Z */ import { type StitchToolClient } from "../../src/client.js"; import { StitchError } from "../../src/spec/errors.js"; +import { DesignTheme, File, ProjectMetadata, ScreenInstance, Typography, UserFeedback, ProjectInput, ScreenInput, Asset, BoundingBox, ComponentRegion, Design, DesignSuggestion, DesignSystemInput, ProgressUpdate, ProgressUpdates, PrototypeLink, PrototypeLinks, PrototypeState, PrototypeV2Spec, ScreenMetadata, SessionOutputComponent, VariantOptions, SelectedScreenInstance } from "./types.generated.js"; +import { ListProjectsResponse, CreateProjectResponse } from "./responses.generated.js"; import { Project } from "../../src/project-ext.js"; /** Main entry point. Manages projects. */ @@ -21,8 +23,8 @@ export class Stitch { */ async projects(): Promise { try { - const raw = await this.client.callTool("list_projects", { }); - return (raw?.projects || []).map((item: any) => new Project(this.client, item)); + const raw = await this.client.callTool("list_projects", { }); + return (raw?.projects || []).map((item) => new Project(this.client, item)); } catch (error) { throw StitchError.fromUnknown(error); } @@ -34,7 +36,7 @@ export class Stitch { */ async createProject(title?: string): Promise { try { - const raw = await this.client.callTool("create_project", { title }); + const raw = await this.client.callTool("create_project", { title }); return new Project(this.client, raw); } catch (error) { throw StitchError.fromUnknown(error); diff --git a/packages/sdk/generated/src/tool-definitions.ts b/packages/sdk/generated/src/tool-definitions.ts index 20d73a3..545a3d5 100644 --- a/packages/sdk/generated/src/tool-definitions.ts +++ b/packages/sdk/generated/src/tool-definitions.ts @@ -3,8 +3,8 @@ DO NOT EDIT — changes will be overwritten. Source: tools-manifest.json (sha256:2f1a623ec115...) - domain-map.json (sha256:baa17d36f4c1...) -Generated: 2026-04-28T16:31:23.449Z + domain-map.json (sha256:ffa082d8fbe7...) +Generated: 2026-04-28T20:49:35.251Z */ /** JSON Schema property descriptor for a tool parameter. */ export interface ToolPropertySchema { diff --git a/packages/sdk/generated/src/types.generated.ts b/packages/sdk/generated/src/types.generated.ts new file mode 100644 index 0000000..dd08ff4 --- /dev/null +++ b/packages/sdk/generated/src/types.generated.ts @@ -0,0 +1,242 @@ +/** + * AUTO-GENERATED by scripts/generate-sdk.ts +DO NOT EDIT — changes will be overwritten. + +Source: tools-manifest.json (sha256:2f1a623ec115...) + domain-map.json (sha256:ffa082d8fbe7...) +Generated: 2026-04-28T20:49:35.251Z + */ + +/** The theme of the design. Next ID: 23 LINT.IfChange */ +export interface DesignTheme { + backgroundDark?: string; + backgroundLight?: string; + bodyFont?: "FONT_UNSPECIFIED" | "BE_VIETNAM_PRO" | "EPILOGUE" | "INTER" | "LEXEND" | "MANROPE" | "NEWSREADER" | "NOTO_SERIF" | "PLUS_JAKARTA_SANS" | "PUBLIC_SANS" | "SPACE_GROTESK" | "SPLINE_SANS" | "WORK_SANS" | "DOMINE" | "LIBRE_CASLON_TEXT" | "EB_GARAMOND" | "LITERATA" | "SOURCE_SERIF_FOUR" | "MONTSERRAT" | "METROPOLIS" | "SOURCE_SANS_THREE" | "NUNITO_SANS" | "ARIMO" | "HANKEN_GROTESK" | "RUBIK" | "GEIST" | "DM_SANS" | "IBM_PLEX_SANS" | "SORA"; + colorMode?: "COLOR_MODE_UNSPECIFIED" | "LIGHT" | "DARK"; + colorVariant?: "COLOR_VARIANT_UNSPECIFIED" | "MONOCHROME" | "NEUTRAL" | "TONAL_SPOT" | "VIBRANT" | "EXPRESSIVE" | "FIDELITY" | "CONTENT" | "RAINBOW" | "FRUIT_SALAD"; + customColor?: string; + description?: string; + designMd?: string; + font?: "FONT_UNSPECIFIED" | "BE_VIETNAM_PRO" | "EPILOGUE" | "INTER" | "LEXEND" | "MANROPE" | "NEWSREADER" | "NOTO_SERIF" | "PLUS_JAKARTA_SANS" | "PUBLIC_SANS" | "SPACE_GROTESK" | "SPLINE_SANS" | "WORK_SANS" | "DOMINE" | "LIBRE_CASLON_TEXT" | "EB_GARAMOND" | "LITERATA" | "SOURCE_SERIF_FOUR" | "MONTSERRAT" | "METROPOLIS" | "SOURCE_SANS_THREE" | "NUNITO_SANS" | "ARIMO" | "HANKEN_GROTESK" | "RUBIK" | "GEIST" | "DM_SANS" | "IBM_PLEX_SANS" | "SORA"; + headlineFont?: "FONT_UNSPECIFIED" | "BE_VIETNAM_PRO" | "EPILOGUE" | "INTER" | "LEXEND" | "MANROPE" | "NEWSREADER" | "NOTO_SERIF" | "PLUS_JAKARTA_SANS" | "PUBLIC_SANS" | "SPACE_GROTESK" | "SPLINE_SANS" | "WORK_SANS" | "DOMINE" | "LIBRE_CASLON_TEXT" | "EB_GARAMOND" | "LITERATA" | "SOURCE_SERIF_FOUR" | "MONTSERRAT" | "METROPOLIS" | "SOURCE_SANS_THREE" | "NUNITO_SANS" | "ARIMO" | "HANKEN_GROTESK" | "RUBIK" | "GEIST" | "DM_SANS" | "IBM_PLEX_SANS" | "SORA"; + labelFont?: "FONT_UNSPECIFIED" | "BE_VIETNAM_PRO" | "EPILOGUE" | "INTER" | "LEXEND" | "MANROPE" | "NEWSREADER" | "NOTO_SERIF" | "PLUS_JAKARTA_SANS" | "PUBLIC_SANS" | "SPACE_GROTESK" | "SPLINE_SANS" | "WORK_SANS" | "DOMINE" | "LIBRE_CASLON_TEXT" | "EB_GARAMOND" | "LITERATA" | "SOURCE_SERIF_FOUR" | "MONTSERRAT" | "METROPOLIS" | "SOURCE_SANS_THREE" | "NUNITO_SANS" | "ARIMO" | "HANKEN_GROTESK" | "RUBIK" | "GEIST" | "DM_SANS" | "IBM_PLEX_SANS" | "SORA"; + namedColors?: Record; + overrideNeutralColor?: string; + overridePrimaryColor?: string; + overrideSecondaryColor?: string; + overrideTertiaryColor?: string; + preset?: string; + roundness?: "ROUNDNESS_UNSPECIFIED" | "ROUND_TWO" | "ROUND_FOUR" | "ROUND_EIGHT" | "ROUND_TWELVE" | "ROUND_FULL"; + saturation?: number; + spacing?: Record; + spacingScale?: number; + typography?: Record; +} + +/** A File resource. */ +export interface File { + downloadUrl?: string; + fileContentBase64?: string; + mimeType?: string; + name?: string; + uploadBlobId?: string; + userFeedback?: UserFeedback; +} + +/** Metadata relating to the project. */ +export interface ProjectMetadata { + isRemixed?: boolean; + userRole?: "ROLE_UNSPECIFIED" | "OWNER" | "READER"; +} + +/** An instance of a screen on the project. Next ID: 15 */ +export interface ScreenInstance { + groupId?: string; + groupName?: string; + height?: number; + hidden?: boolean; + id?: string; + isFavourite?: boolean; + label?: string; + sourceAsset?: string; + sourceScreen?: string; + type?: "SCREEN_INSTANCE_TYPE_UNSPECIFIED" | "SCREEN_INSTANCE" | "DESIGN_SYSTEM_INSTANCE" | "GROUP_INSTANCE"; + variantScreenInstance?: ScreenInstance; + width?: number; + x?: number; + y?: number; +} + +/** A typography style token in a design system, defining the visual properties for a single text level (e.g. "display-lg", "body-md"). */ +export interface Typography { + fontFamily?: string; + fontSize?: string; + fontWeight?: string; + letterSpacing?: string; + lineHeight?: string; +} + +/** User feedback for a given interaction. */ +export interface UserFeedback { + comment?: string; + designFeedbackReason?: "DESIGN_FEEDBACK_REASON_UNSPECIFIED" | "DESIGN_DOESNT_MATCH_PROMPT" | "EDIT_DOESNT_MATCH_PROMPT" | "DESCRIPTION_DOESNT_MATCH" | "COMPONENT_ISSUE" | "INCORRECT_THEME" | "FIGMA_EXPORT_FAILED" | "OTHER"; + rating?: "RATING_UNSPECIFIED" | "POSITIVE" | "NEGATIVE"; +} + +/** A project is a single app. */ +export interface ProjectInput { + backgroundTheme?: string; + createTime?: string; + designTheme?: DesignTheme; + deviceType?: "DEVICE_TYPE_UNSPECIFIED" | "MOBILE" | "DESKTOP" | "TABLET" | "AGNOSTIC"; + metadata?: ProjectMetadata; + name?: string; + origin?: "ORIGIN_UNSPECIFIED" | "STITCH" | "IMPORTED_FROM_GALILEO"; + projectType?: "PROJECT_TYPE_UNSPECIFIED" | "TEXT_TO_UI" | "TEXT_TO_UI_PRO" | "TEXT_TO_UI_PRO_IMAGE_SPACE" | "IMAGE_TO_UI" | "IMAGE_TO_UI_PRO" | "PROJECT_DESIGN"; + readTime?: string; + screenInstances?: ScreenInstance[]; + thumbnailScreenshot?: File; + title?: string; + updateTime?: string; + visibility?: "VISIBILITY_UNSPECIFIED" | "PUBLIC" | "PRIVATE"; +} + +/** A generated screen. */ +export interface ScreenInput { + designSystem?: Asset; + deviceType?: "DEVICE_TYPE_UNSPECIFIED" | "MOBILE" | "DESKTOP" | "TABLET" | "AGNOSTIC"; + figmaExport?: File; + generatedBy?: string; + groupId?: string; + groupName?: string; + height?: string; + htmlCode?: File; + id?: string; + isCreatedByClient?: boolean; + name?: string; + prompt?: string; + screenMetadata?: ScreenMetadata; + screenType?: "SCREEN_TYPE_UNSPECIFIED" | "DESIGN" | "IMAGE" | "PROTOTYPE" | "DOCUMENT" | "PROTOTYPE_V2"; + screenshot?: File; + theme?: DesignTheme; + title?: string; + width?: string; +} + +/** An Asset resource. */ +export interface Asset { + copiedFrom?: string; + designSystem?: DesignSystemInput; + name?: string; + version?: string; +} + +/** The bounding box of a component. */ +export interface BoundingBox { + height?: number; + width?: number; + x?: number; + y?: number; +} + +/** The region of a component region. */ +export interface ComponentRegion { + boundingBox?: BoundingBox; + description?: string; + type?: "COMPONENT_TYPE_UNSPECIFIED" | "COMPONENT_TYPE_IMAGE" | "COMPONENT_TYPE_TEXT" | "COMPONENT_TYPE_BUTTON" | "COMPONENT_TYPE_INPUT" | "COMPONENT_TYPE_CONTAINER"; + xpath?: string; +} + +/** A generated design, which is a collection of multiple screens. Note: do not add new fields to this message or rely on it for any logic. For any Screen property, add it to Screen message instead. Design is a deprecated hierarchical container for screens and is only used for backward compatibility. */ +export interface Design { + deviceType?: "DEVICE_TYPE_UNSPECIFIED" | "MOBILE" | "DESKTOP" | "TABLET" | "AGNOSTIC"; + screens?: ScreenInput[]; + theme?: DesignTheme; + title?: string; +} + +/** A suggested next action for a screen. */ +export interface DesignSuggestion { + label?: string; + prompt?: string; +} + +/** Represents a collection of guidelines, design tokens, and reusable components that define the visual and functional characteristics of a product's design. This is used to guide the generation of consistent and branded designs. LINT.IfChange */ +export interface DesignSystemInput { + designTokens?: string; + displayName?: string; + styleGuidelines?: string; + theme?: DesignTheme; +} + +export interface ProgressUpdate { + displayName?: string; + status?: "STATUS_UNSPECIFIED" | "STARTED" | "COMPLETED" | "FAILED"; + toolCallId?: string; + toolName?: string; +} + +export interface ProgressUpdates { + updates?: ProgressUpdate[]; +} + +/** A single interactive link within a prototype screen. */ +export interface PrototypeLink { + state?: string; + targetScreenId?: string; + transition?: "TRANSITION_TYPE_UNSPECIFIED" | "NONE" | "PUSH" | "PUSH_BACK" | "SLIDE_UP"; + xpath?: string; +} + +/** A collection of prototype links for a single screen. */ +export interface PrototypeLinks { + links?: PrototypeLink[]; +} + +/** A named state that controls link behavior in a prototype. */ +export interface PrototypeState { + name?: string; +} + +/** The full specification for a V2 interactive prototype. */ +export interface PrototypeV2Spec { + initScreenId?: string; + links?: Record; + screenIds?: string[]; + states?: PrototypeState[]; +} + +/** The metadata of a screen. */ +export interface ScreenMetadata { + agentType?: "DESIGN_AGENT_TYPE_UNSPECIFIED" | "TURBO_AGENT" | "PRO_AGENT" | "IMAGE_AGENT" | "GENIE_AGENT" | "IMAGE_PRO_AGENT" | "HATTER_AGENT" | "GEMINI_3_AGENT"; + componentRegions?: ComponentRegion[]; + displayMode?: "DISPLAY_MODE_UNSPECIFIED" | "SCREENSHOT" | "HTML" | "CODE" | "MARKDOWN" | "STICKY_NOTE" | "SVG" | "TABLE" | "MERMAID" | "CHECKLIST" | "CHART"; + isRemixed?: boolean; + prototypeSpec?: PrototypeV2Spec; + status?: "SCREEN_STATUS_UNSPECIFIED" | "IN_PROGRESS" | "COMPLETE" | "FAILED"; + statusMessage?: string; + suggestions?: DesignSuggestion[]; + summary?: string; +} + +/** A partial output of the session. */ +export interface SessionOutputComponent { + design?: Design; + designSystem?: Asset; + progressUpdates?: ProgressUpdates; + suggestion?: string; + text?: string; +} + +/** Configuration options for design variant generation. This message captures all parameters used to generate variants, allowing the configuration to be stored, replayed, or analyzed. */ +export interface VariantOptions { + aspects?: "VARIANT_ASPECT_UNSPECIFIED" | "LAYOUT" | "COLOR_SCHEME" | "IMAGES" | "TEXT_FONT" | "TEXT_CONTENT"[]; + creativeRange?: "CREATIVE_RANGE_UNSPECIFIED" | "REFINE" | "EXPLORE" | "REIMAGINE"; + variantCount?: number; +} + +/** A screen instance to be edited by the agent, selected by the user. */ +export interface SelectedScreenInstance { + id: string; + sourceScreen: string; +} diff --git a/packages/sdk/generated/stitch-sdk.lock b/packages/sdk/generated/stitch-sdk.lock index eb24c0b..cfac18b 100644 --- a/packages/sdk/generated/stitch-sdk.lock +++ b/packages/sdk/generated/stitch-sdk.lock @@ -1,15 +1,15 @@ { "schemaVersion": 1, "generated": { - "generatedAt": "2026-04-28T16:28:59.433Z", - "sourceHash": "sha256:b4fb8be0bcd902ce625563a42baae8fe8bbab4880b92b5d455d35eca90489e8f", + "generatedAt": "2026-04-28T20:49:35.425Z", + "sourceHash": "sha256:da5ac171e3d4f0548a4241c29a7c7980dcedfebfbdb794d04c2d5f75855db700", "manifestHash": "sha256:2f1a623ec115abb1b93a059223ce201160fe433aeb23436e76408e09391cfede", - "domainMapHash": "sha256:baa17d36f4c1cc5a7b7121800cf69fccd47e9a5fe41bd9838a7819826f4c0a56", - "fileCount": 6 + "domainMapHash": "sha256:ffa082d8fbe7f7bbf1ee972aea66ec377f32cd065244aa8f656664cf263dae09", + "fileCount": 8 }, "domainMap": { - "generatedAt": "2026-04-28T16:28:59.433Z", - "sourceHash": "sha256:baa17d36f4c1cc5a7b7121800cf69fccd47e9a5fe41bd9838a7819826f4c0a56", + "generatedAt": "2026-04-28T20:49:35.425Z", + "sourceHash": "sha256:ffa082d8fbe7f7bbf1ee972aea66ec377f32cd065244aa8f656664cf263dae09", "manifestHash": "sha256:2f1a623ec115abb1b93a059223ce201160fe433aeb23436e76408e09391cfede", "classCount": 4, "bindingCount": 13 diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 927c81c..1336314 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@google/stitch-sdk", - "version": "0.1.1", + "version": "0.1.2-next.1", "type": "module", "private": false, "description": "Generate UI screens from text prompts and extract their HTML and screenshots programmatically.", @@ -46,7 +46,8 @@ ], "publishConfig": { "registry": "https://wombat-dressing-room.appspot.com", - "access": "public" + "access": "public", + "tag": "next" }, "scripts": { "build": "bun scripts/inject-version.ts && tsc", diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 995b4e2..bd93656 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -191,7 +191,13 @@ export class StitchToolClient implements StitchToolClientSpec { * Make a direct REST POST to the Stitch API. * * Used for endpoints not available as MCP tools (e.g. BatchCreateScreens). - * Reuses the same auth headers as callTool. Throws StitchError on HTTP errors. + * Reuses the same auth headers as callTool — both API key and OAuth Bearer + * token are supported by all current REST POST endpoints. + * + * Throws StitchError on HTTP errors. Common failure modes: + * - 401 CREDENTIALS_MISSING → the API key was empty (source .env first) + * - 403 PERMISSION_DENIED → the key doesn't own the target project + * Neither means "API keys are unsupported." See upload-handler.ts for full context. */ async httpPost(path: string, body: unknown): Promise { const url = `${this.config.baseUrl.replace(/\/mcp$/, '').replace(/\/$/, '')}/v1/${path}`; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 1863b40..a2f7ea7 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -38,6 +38,9 @@ export { StitchError, StitchErrorCode } from "./spec/errors.js"; // FIFE URL utilities export { buildFifeSuffix, type FifeImageOptions } from "./fife.js"; +// Resource name utilities +export { parseResourceName } from "./utils.js"; + // Tool catalog (generated) export { toolDefinitions, diff --git a/packages/sdk/src/upload-handler.ts b/packages/sdk/src/upload-handler.ts index a7b12c6..8be0822 100644 --- a/packages/sdk/src/upload-handler.ts +++ b/packages/sdk/src/upload-handler.ts @@ -21,6 +21,18 @@ * API. The SDK runs as Node.js server-side code, so it can read files from * disk and send arbitrarily large base64-encoded payloads — unlike agent- * driven MCP calls, which are constrained by output token limits (~16K). + * + * AUTHENTICATION: + * API keys (X-Goog-Api-Key) ARE accepted by BatchCreateScreens. Both API key + * and OAuth Bearer token authentication work. + * + * DEBUGGING TRAPS (do not repeat): + * 1. A 401 CREDENTIALS_MISSING means the key was EMPTY, not that the mechanism + * is unsupported. Always verify: echo $STITCH_API_KEY (source .env first). + * 2. A 403 PERMISSION_DENIED means the key is valid but doesn't own the project. + * The user tests across two accounts — check key/project ownership pairing. + * 3. Neither error means "API keys are unsupported." Do NOT add OAuth-only + * documentation based on these errors. */ import * as path from 'node:path'; diff --git a/packages/sdk/src/utils.ts b/packages/sdk/src/utils.ts new file mode 100644 index 0000000..4203732 --- /dev/null +++ b/packages/sdk/src/utils.ts @@ -0,0 +1,29 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Parse a Google API resource name into a bare ID. + * + * Google API resource names follow the pattern `{collection}/{id}/{collection}/{id}`. + * This utility extracts the last segment (the bare ID) from any resource name. + * + * @example + * parseResourceName("projects/123/screens/abc") // → "abc" + * parseResourceName("projects/123") // → "123" + * parseResourceName("abc123") // → "abc123" (pass-through) + */ +export function parseResourceName(name: string): string { + if (!name || !name.includes("/")) return name; + return name.split("/").pop()!; +} diff --git a/packages/sdk/src/version.ts b/packages/sdk/src/version.ts index d0b1be3..39a3b7c 100644 --- a/packages/sdk/src/version.ts +++ b/packages/sdk/src/version.ts @@ -1,2 +1,2 @@ // Auto-generated by scripts/inject-version.ts — do not edit. -export const SDK_VERSION = '0.1.0'; +export const SDK_VERSION = '0.1.2-next.1'; diff --git a/packages/sdk/test/integration/live.test.ts b/packages/sdk/test/integration/live.test.ts index 15db251..9c7cce0 100644 --- a/packages/sdk/test/integration/live.test.ts +++ b/packages/sdk/test/integration/live.test.ts @@ -52,15 +52,18 @@ runIfConfigured("Stitch Live Integration", () => { }, 30000); }); -// ─── E2E: uploadImage (REST path, API key auth) ─────────────────────────────── - -const runIfApiKey = process.env.STITCH_API_KEY ? describe : describe.skip; - -runIfApiKey("Project.uploadImage (E2E)", () => { +// ─── E2E: uploadImage (REST path) ──────────────────────────────────────────── +// AUTH: BatchCreateScreens accepts API keys. Do NOT change this to use OAuth. +// If this test fails with 401, the STITCH_API_KEY env var is empty (source .env). +// If it fails with 403, the key doesn't own the project (account mismatch). +// See upload-handler.ts DEBUGGING TRAPS for full context. +const runIfKey = process.env.STITCH_API_KEY ? describe : describe.skip; + +runIfKey("Project.uploadImage (E2E)", () => { let client: StitchToolClient; let project: Project; - const FIXTURE_PNG = resolve(import.meta.dirname, "../fixtures/real-image.png"); + const FIXTURE_PNG = resolve(import.meta.dirname, "../fixtures/1x1.png"); beforeAll(async () => { client = new StitchToolClient({ apiKey: process.env.STITCH_API_KEY }); diff --git a/packages/sdk/test/unit/parse-resource-name.test.ts b/packages/sdk/test/unit/parse-resource-name.test.ts new file mode 100644 index 0000000..fe5881e --- /dev/null +++ b/packages/sdk/test/unit/parse-resource-name.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { parseResourceName } from '../../src/index.js'; + +describe('parseResourceName', () => { + it('should extract the bare ID from a multi-segment resource name', () => { + expect(parseResourceName('projects/123/screens/abc')).toBe('abc'); + }); + + it('should extract the ID from a single collection/id pair', () => { + expect(parseResourceName('projects/123')).toBe('123'); + }); + + it('should pass through a bare ID unchanged', () => { + expect(parseResourceName('abc123')).toBe('abc123'); + }); + + it('should handle deeply nested resource names', () => { + expect(parseResourceName('projects/123/screens/abc/variants/v1')).toBe('v1'); + }); + + it('should handle empty string', () => { + expect(parseResourceName('')).toBe(''); + }); + + it('should handle a resource name with a trailing slash', () => { + // Edge case: trailing slash should return empty string (last segment) + expect(parseResourceName('projects/123/')).toBe(''); + }); +}); diff --git a/packages/sdk/test/unit/screen-factory.test.ts b/packages/sdk/test/unit/screen-factory.test.ts new file mode 100644 index 0000000..97d81e7 --- /dev/null +++ b/packages/sdk/test/unit/screen-factory.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Stitch } from '../../generated/src/stitch.js'; +import { Screen } from '../../generated/src/screen.js'; +import { StitchToolClient } from '../../src/client.js'; + +describe('Project.screen() factory', () => { + const SCREEN_ID = 'abc123def456'; + const PROJECT_ID = '999'; + + function makeStitch() { + const mockClient = new StitchToolClient({ apiKey: 'fake' }); + return { stitch: new Stitch(mockClient), mockClient }; + } + + it('should create a Screen handle from an ID without an API call', () => { + const { stitch, mockClient } = makeStitch(); + const connectSpy = vi.spyOn(mockClient, 'connect'); + + const project = stitch.project(PROJECT_ID); + const screen = project.screen(SCREEN_ID); + + // No API call should have been made + expect(connectSpy).not.toHaveBeenCalled(); + + // Should return a Screen instance + expect(screen).toBeInstanceOf(Screen); + }); + + it('should populate screenId and projectId correctly', () => { + const { stitch } = makeStitch(); + const project = stitch.project(PROJECT_ID); + const screen = project.screen(SCREEN_ID); + + expect(screen.id).toBe(SCREEN_ID); + expect(screen.screenId).toBe(SCREEN_ID); + expect(screen.projectId).toBe(PROJECT_ID); + }); + + it('should produce a screen that can call edit()', async () => { + const { stitch, mockClient } = makeStitch(); + vi.spyOn(mockClient, 'callTool').mockResolvedValue({ + outputComponents: [ + { design: { screens: [{ id: 'new-screen-id', projectId: PROJECT_ID }] } } + ] + }); + + const project = stitch.project(PROJECT_ID); + const screen = project.screen(SCREEN_ID); + const edited = await screen.edit('make it blue'); + + expect(edited).toBeInstanceOf(Screen); + expect(edited.id).toBe('new-screen-id'); + }); + + it('should produce a screen that can call getHtml() via API', async () => { + const { stitch, mockClient } = makeStitch(); + vi.spyOn(mockClient, 'callTool').mockResolvedValue({ + htmlCode: { downloadUrl: 'https://example.com/html' } + }); + + const project = stitch.project(PROJECT_ID); + const screen = project.screen(SCREEN_ID); + const html = await screen.getHtml(); + + expect(html).toBe('https://example.com/html'); + }); +}); diff --git a/scripts/generate-sdk.ts b/scripts/generate-sdk.ts index e9600a4..3245f80 100644 --- a/scripts/generate-sdk.ts +++ b/scripts/generate-sdk.ts @@ -284,13 +284,30 @@ export function emitCacheProjection(steps: ProjectionStep[]): string { return code; } -// ── Param Type Generation ───────────────────────────────────── - /** * Convert JSON Schema type to TypeScript type. + * Supports primitives, enums, arrays, objects with properties, and $ref. */ -export function jsonSchemaToTs(prop: ToolSchema | null | undefined): string { +export function jsonSchemaToTs( + prop: ToolSchema | null | undefined, + defs?: Record, + namedTypes?: Map, +): string { if (!prop) return "any"; + + // Resolve $ref before anything else + if (prop.$ref) { + const refName = prop.$ref.replace("#/$defs/", ""); + const mappedName = namedTypes?.get(refName); + if (mappedName) return mappedName; + console.log(`[DEBUG] Resolving $ref "${refName}" recursively because it was not in namedTypes.`); + const resolved = defs?.[refName]; + return resolved ? jsonSchemaToTs(resolved, defs, namedTypes) : "any"; + } + + // Merge $defs from current schema into the defs context + const allDefs = { ...defs, ...prop.$defs }; + if (prop.enum) { return prop.enum.map((v: string) => `"${v}"`).join(" | "); } @@ -300,24 +317,98 @@ export function jsonSchemaToTs(prop: ToolSchema | null | undefined): string { case "number": return "number"; case "boolean": return "boolean"; case "array": - if (prop.items) return `${jsonSchemaToTs(prop.items)}[]`; + if (prop.items) return `${jsonSchemaToTs(prop.items, allDefs, namedTypes)}[]`; return "any[]"; - case "object": return "any"; + case "object": + if (prop.properties) { + return emitObjectLiteral(prop, allDefs, namedTypes); + } + if (prop.additionalProperties) { + return `Record`; + } + return "Record"; default: return "any"; } } +/** + * Emit an inline TypeScript object literal type from a JSON Schema with properties. + * e.g. { name: string; count?: number } + */ +function emitObjectLiteral( + schema: ToolSchema, + defs?: Record, + namedTypes?: Map, +): string { + const props = schema.properties!; + const required = new Set(schema.required || []); + const fields = Object.entries(props).map(([name, fieldSchema]) => { + const opt = required.has(name) ? "" : "?"; + const type = jsonSchemaToTs(fieldSchema, defs, namedTypes); + return `${name}${opt}: ${type}`; + }); + return `{ ${fields.join("; ")} }`; +} + +/** + * Emit TypeScript interfaces from JSON Schema $defs. + */ +export function emitNamedInterfaces(defs: Record, namedTypes: Map): string { + const interfaces: string[] = []; + for (const [name, schema] of Object.entries(defs)) { + const desc = schema.description ? `/** ${schema.description} */\n` : ""; + const typeLit = emitObjectLiteral(schema, defs, namedTypes); + // Convert `{ a: string; b: string }` -> `{\n a: string;\n b: string;\n}` + const intf = typeLit === "{ }" ? "{}" : typeLit + .replace(/^{ /, "{\n ") + .replace(/ }$/, ";\n}") + .replace(/; /g, ";\n "); + interfaces.push(`${desc}export interface ${name} ${intf}`); + } + return interfaces.join("\n\n"); +} + +/** + * Convert a snake_case tool name to a PascalCase response name. + * e.g. "list_screens" -> "ListScreensResponse" + */ +function toResponseName(toolName: string): string { + return toolName.split("_").map(part => part.charAt(0).toUpperCase() + part.slice(1)).join("") + "Response"; +} + +/** + * Emit TypeScript interfaces for a tool's outputSchema. + */ +export function emitResponseType(tool: Tool, namedTypes?: Map): string { + const schema = tool.outputSchema; + if (!schema || schema.type !== "object" || !schema.properties) { + return `export interface ${toResponseName(tool.name)} {}`; + } + const desc = tool.description ? `/** Response message for ${tool.name}. */\n` : ""; + const typeLit = emitObjectLiteral(schema, tool.outputSchema?.$defs, namedTypes); + const intf = typeLit === "{ }" ? "{}" : typeLit + .replace(/^{ /, "{\n ") + .replace(/ }$/, ";\n}") + .replace(/; /g, ";\n "); + return `${desc}export interface ${toResponseName(tool.name)} ${intf}`; +} + /** * Convert a tool's inputSchema properties to TypeScript param types. * Types are derived from the manifest inputSchema, not hardcoded in domain-map. */ -function generateParamType(tool: Tool, args: Record): string { +export function generateParamType( + tool: Tool, + args: Record, + namedTypes?: Map, +): string { const params: string[] = []; for (const [name, spec] of Object.entries(args)) { if (spec.from !== "param") continue; const paramName = spec.rename || name; const toolProp = tool.inputSchema?.properties?.[name]; - const tsType = jsonSchemaToTs(toolProp); + const defs = tool.inputSchema?.$defs; + const tsType = jsonSchemaToTs(toolProp, defs, namedTypes); const optional = spec.optional ? "?" : ""; params.push(`${paramName}${optional}: ${tsType}`); } @@ -373,7 +464,7 @@ function generateReturnExpression( ? `{ ...item, ${parentField}: this.${parentField} }` : "item"; // Null-safe: default to empty array if projection yields undefined - return `(${projectionExpr} || []).map((item: any) => new ${binding.returns.class}(this.client, ${itemExpr}))`; + return `(${projectionExpr} || []).map((item) => new ${binding.returns.class}(this.client, ${itemExpr}))`; } // Only emit guard when projection has actual steps (not just `raw`) @@ -454,7 +545,8 @@ function buildMethodBody( } statements.push(`try {`); - statements.push(` const raw = await this.client.callTool("${binding.tool}", ${generateArgsObject(binding.args)});`); + const responseName = toResponseName(binding.tool); + statements.push(` const raw = await this.client.callTool<${responseName}>("${binding.tool}", ${generateArgsObject(binding.args)});`); const retExpr = generateReturnExpression(binding, className, domainMap); // If retExpr contains newlines, it has guard statements — don't wrap in return if (retExpr.includes("\n")) { @@ -560,7 +652,49 @@ async function main() { `Generated: ${new Date().toISOString()}`, ].join("\n"); + // ── Phase A: Generate named input types ───────────────────── + const allDefs: Record = {}; + for (const tool of manifest) { + if (tool.inputSchema?.$defs) { + Object.assign(allDefs, tool.inputSchema.$defs); + } + if (tool.outputSchema?.$defs) { + Object.assign(allDefs, tool.outputSchema.$defs); + } + } + // Map original def name -> emitted TS name (handles collisions) + const namedTypes = new Map(); + const renamedDefs: Record = {}; + for (const [name, def] of Object.entries(allDefs)) { + // If it collides with a domain class, append "Input" + const newName = domainMap.classes[name] ? `${name}Input` : name; + namedTypes.set(name, newName); + renamedDefs[newName] = def; + } + let fileCount = 0; + const typesFile = tsProject.createSourceFile("types.generated.ts"); + typesFile.addStatements(`/**\n * ${headerComment}\n */\n\n${emitNamedInterfaces(renamedDefs, namedTypes)}`); + await Bun.write(resolve(GENERATED_DIR, "types.generated.ts"), typesFile.getFullText()); + fileCount++; + + // ── Phase B: Generate named response types ──────────────────── + const responsesFile = tsProject.createSourceFile("responses.generated.ts"); + const responseTypes: string[] = []; + if (namedTypes.size > 0) { + responsesFile.addImportDeclaration({ + moduleSpecifier: "./types.generated.js", + namedImports: Array.from(namedTypes.values()), + }); + } + for (const tool of manifest) { + responseTypes.push(emitResponseType(tool, namedTypes)); + } + responsesFile.addStatements(`/**\n * ${headerComment}\n */\n\n${responseTypes.join("\n\n")}`); + await Bun.write(resolve(GENERATED_DIR, "responses.generated.ts"), responsesFile.getFullText()); + fileCount++; + + let fileCountTotal = fileCount; // Generate a class file for each domain class for (const [className, config] of Object.entries(domainMap.classes)) { @@ -599,6 +733,25 @@ async function main() { moduleSpecifier: "../../src/spec/errors.js", namedImports: ["StitchError"], }); + if (namedTypes.size > 0) { + sourceFile.addImportDeclaration({ + moduleSpecifier: "./types.generated.js", + namedImports: Array.from(namedTypes.values()), + }); + } + + // Import response types used by bindings in this class + const requiredResponses = new Set(); + for (const b of classBindings) { + requiredResponses.add(toResponseName(b.tool)); + } + if (requiredResponses.size > 0) { + sourceFile.addImportDeclaration({ + moduleSpecifier: "./responses.generated.js", + namedImports: Array.from(requiredResponses), + }); + } + for (const rc of returnClasses) { const targetClassConfig = domainMap.classes[rc]; if (targetClassConfig?.extensionPath) { @@ -662,7 +815,7 @@ async function main() { continue; } - const paramTypes = generateParamType(tool, binding.args); + const paramTypes = generateParamType(tool, binding.args, namedTypes); const returnTypeStr = binding.returns.class ? (binding.returns.array ? `${binding.returns.class}[]` : binding.returns.class) : (binding.returns.type || "any"); @@ -806,6 +959,16 @@ async function main() { { name: "ToolPropertySchema", isTypeOnly: true }, ], }); + if (namedTypes.size > 0) { + indexFile.addExportDeclaration({ + moduleSpecifier: "./types.generated.js", + isTypeOnly: true, + }); + } + indexFile.addExportDeclaration({ + moduleSpecifier: "./responses.generated.js", + isTypeOnly: true, + }); await Bun.write(resolve(GENERATED_DIR, "index.ts"), indexFile.getFullText()); fileCount++; diff --git a/scripts/test/generate-sdk.test.ts b/scripts/test/generate-sdk.test.ts index d5cf4cd..bb773c6 100644 --- a/scripts/test/generate-sdk.test.ts +++ b/scripts/test/generate-sdk.test.ts @@ -27,7 +27,10 @@ import { emitCacheProjection, validateProjection, jsonSchemaToTs, + emitNamedInterfaces, + emitResponseType, generateArgsObject, + generateParamType, resolveRef, } from "../generate-sdk.js"; @@ -238,8 +241,146 @@ describe("jsonSchemaToTs", () => { expect(jsonSchemaToTs(undefined)).toBe("any"); }); - test("object type → any", () => { - expect(jsonSchemaToTs({ type: "object" })).toBe("any"); + test("bare object type → Record", () => { + expect(jsonSchemaToTs({ type: "object" })).toBe("Record"); + }); + + test("object with properties → inline type literal", () => { + const result = jsonSchemaToTs({ + type: "object", + properties: { + name: { type: "string" }, + count: { type: "integer" }, + }, + required: ["name"], + }); + expect(result).toBe("{ name: string; count?: number }"); + }); + + test("$ref resolves to inline type via defs", () => { + const defs = { + Typography: { + type: "object" as const, + properties: { + fontSize: { type: "string" as const }, + fontWeight: { type: "string" as const }, + }, + }, + }; + const result = jsonSchemaToTs({ $ref: "#/$defs/Typography" }, defs); + expect(result).toBe("{ fontSize?: string; fontWeight?: string }"); + }); + + test("object with additionalProperties → Record", () => { + const result = jsonSchemaToTs({ + type: "object", + additionalProperties: { type: "string" }, + }); + expect(result).toBe("Record"); + }); + + test("object with additionalProperties $ref → Record", () => { + const defs = { + Typography: { + type: "object" as const, + properties: { fontSize: { type: "string" as const } }, + }, + }; + const result = jsonSchemaToTs( + { type: "object", additionalProperties: { $ref: "#/$defs/Typography" } }, + defs, + ); + expect(result).toBe("Record"); + }); + + test("$ref resolves to named type when in namedTypes map", () => { + const namedTypes = new Map([["Typography", "Typography"]]); + const result = jsonSchemaToTs( + { $ref: "#/$defs/Typography" }, + { Typography: { type: "object" as const, properties: { fontSize: { type: "string" as const } } } }, + namedTypes, + ); + expect(result).toBe("Typography"); + }); +}); + +// ── emitNamedInterfaces ────────────────────────────────────── + +describe("emitNamedInterfaces", () => { + test("generates interfaces from $defs", () => { + const defs = { + Typography: { + type: "object" as const, + description: "A typography token.", + properties: { + fontSize: { type: "string" as const, description: "CSS font-size." }, + fontWeight: { type: "string" as const }, + }, + }, + }; + const namedTypes = new Map([["Typography", "Typography"]]); + const result = emitNamedInterfaces(defs, namedTypes); + expect(result).toContain("export interface Typography {"); + expect(result).toContain("fontSize?: string;"); + expect(result).toContain("fontWeight?: string;"); + expect(result).toContain("/** A typography token. */"); + }); +}); + +// ── generateParamType ─────────────────────────────────────── + +describe("generateParamType", () => { + test("uses named type for $ref", () => { + const tool: any = { + name: "apply_design_system", + inputSchema: { + properties: { + selectedScreenInstances: { + type: "array", + items: { $ref: "#/$defs/SelectedScreenInstance" }, + }, + }, + $defs: { + SelectedScreenInstance: { + type: "object", + properties: { id: { type: "string" }, sourceScreen: { type: "string" } }, + required: ["id", "sourceScreen"], + }, + }, + }, + }; + const namedTypes = new Map([["SelectedScreenInstance", "SelectedScreenInstance"]]); + const args = { selectedScreenInstances: { from: "param" as const } }; + const result = generateParamType(tool, args as any, namedTypes); + expect(result).toContain("selectedScreenInstances: SelectedScreenInstance[]"); + }); +}); + +// ── emitResponseType ──────────────────────────────────────── + +describe("emitResponseType", () => { + test("generates interface from outputSchema", () => { + const tool: any = { + name: "list_screens", + outputSchema: { + type: "object", + properties: { + screens: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + id: { type: "string" }, + }, + }, + }, + }, + }, + }; + const result = emitResponseType(tool, new Map()); + expect(result).toContain("export interface ListScreensResponse {"); + expect(result).toContain("screens?: { name?: string;\n id?: string }[];"); }); }); diff --git a/scripts/tool-schema.ts b/scripts/tool-schema.ts index 48f667c..6625b26 100644 --- a/scripts/tool-schema.ts +++ b/scripts/tool-schema.ts @@ -28,6 +28,7 @@ export interface ToolSchema { items?: ToolSchema; $ref?: string; $defs?: Record; + additionalProperties?: ToolSchema; enum?: string[]; description?: string; required?: string[]; From b7d9d0ac17dda0325cbaee9a01b7360e362e1309 Mon Sep 17 00:00:00 2001 From: David East Date: Wed, 29 Apr 2026 10:07:48 -0400 Subject: [PATCH 2/5] chore: bump sdk to 0.1.2-next.2 --- packages/sdk/package.json | 2 +- packages/sdk/src/version.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 1336314..8337207 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@google/stitch-sdk", - "version": "0.1.2-next.1", + "version": "0.1.2-next.2", "type": "module", "private": false, "description": "Generate UI screens from text prompts and extract their HTML and screenshots programmatically.", diff --git a/packages/sdk/src/version.ts b/packages/sdk/src/version.ts index 39a3b7c..405c6ef 100644 --- a/packages/sdk/src/version.ts +++ b/packages/sdk/src/version.ts @@ -1,2 +1,2 @@ // Auto-generated by scripts/inject-version.ts — do not edit. -export const SDK_VERSION = '0.1.2-next.1'; +export const SDK_VERSION = '0.1.2-next.2'; From 5caec2801fefb6adf3f526cd4e10af81e8af984a Mon Sep 17 00:00:00 2001 From: David East Date: Wed, 29 Apr 2026 10:17:04 -0400 Subject: [PATCH 3/5] chore: bump sdk to 0.1.2-next.3 --- packages/sdk/package.json | 2 +- packages/sdk/src/version.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 8337207..10120c1 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@google/stitch-sdk", - "version": "0.1.2-next.2", + "version": "0.1.2-next.3", "type": "module", "private": false, "description": "Generate UI screens from text prompts and extract their HTML and screenshots programmatically.", diff --git a/packages/sdk/src/version.ts b/packages/sdk/src/version.ts index 405c6ef..3a96f9a 100644 --- a/packages/sdk/src/version.ts +++ b/packages/sdk/src/version.ts @@ -1,2 +1,2 @@ // Auto-generated by scripts/inject-version.ts — do not edit. -export const SDK_VERSION = '0.1.2-next.2'; +export const SDK_VERSION = '0.1.2-next.3'; From 7cfb37feba44c7b9e87e437df73389da996b029f Mon Sep 17 00:00:00 2001 From: David East Date: Wed, 29 Apr 2026 10:18:18 -0400 Subject: [PATCH 4/5] chore: bump sdk to 0.1.2-next.4 --- packages/sdk/package.json | 2 +- packages/sdk/src/version.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 10120c1..1b92a2f 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@google/stitch-sdk", - "version": "0.1.2-next.3", + "version": "0.1.2-next.4", "type": "module", "private": false, "description": "Generate UI screens from text prompts and extract their HTML and screenshots programmatically.", diff --git a/packages/sdk/src/version.ts b/packages/sdk/src/version.ts index 3a96f9a..482429d 100644 --- a/packages/sdk/src/version.ts +++ b/packages/sdk/src/version.ts @@ -1,2 +1,2 @@ // Auto-generated by scripts/inject-version.ts — do not edit. -export const SDK_VERSION = '0.1.2-next.3'; +export const SDK_VERSION = '0.1.2-next.4'; From d508480624a9247f823ba03e1149606b52b645df Mon Sep 17 00:00:00 2001 From: David East Date: Tue, 5 May 2026 18:26:48 -0400 Subject: [PATCH 5/5] chore: bump sdk version to 0.2.0 stable --- packages/sdk/package.json | 2 +- packages/sdk/src/version.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 1b92a2f..bae31c2 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@google/stitch-sdk", - "version": "0.1.2-next.4", + "version": "0.2.0", "type": "module", "private": false, "description": "Generate UI screens from text prompts and extract their HTML and screenshots programmatically.", diff --git a/packages/sdk/src/version.ts b/packages/sdk/src/version.ts index 482429d..6d83c55 100644 --- a/packages/sdk/src/version.ts +++ b/packages/sdk/src/version.ts @@ -1,2 +1,2 @@ // Auto-generated by scripts/inject-version.ts — do not edit. -export const SDK_VERSION = '0.1.2-next.4'; +export const SDK_VERSION = '0.2.0';