diff --git a/.changeset/migrate-api-client-npm.md b/.changeset/migrate-api-client-npm.md new file mode 100644 index 00000000..ce600a86 --- /dev/null +++ b/.changeset/migrate-api-client-npm.md @@ -0,0 +1,16 @@ +--- +"perstack": patch +"@perstack/runtime": patch +--- + +Migrate @perstack/api-client to npm version (^0.0.51) + +BREAKING CHANGES: +- Remove `perstack publish` command (new API uses draft-based publish model) +- Remove `perstack status` command (no status update in new API) +- Remove `perstack tag` command (no tag update in new API) +- Remove `perstack unpublish` command (CLI no longer supports registry operations) + +Changes: +- Update API calls from `client.registry.experts.*` to `client.experts.*` +- Adapt response handling for new API structure where experts are nested under `definition.experts` diff --git a/apps/create-expert/src/lib/agents-md-template.ts b/apps/create-expert/src/lib/agents-md-template.ts index 766ceeba..63f19409 100644 --- a/apps/create-expert/src/lib/agents-md-template.ts +++ b/apps/create-expert/src/lib/agents-md-template.ts @@ -19,7 +19,6 @@ Perstack is a package manager and runtime for agent-first development. It enable Key concepts: - **Experts**: Modular micro-agents defined in TOML - **Runtime**: Executes Experts with isolation, observability, and sandbox support -- **Registry**: Public registry for sharing and reusing Experts ## Project Configuration diff --git a/apps/perstack/bin/cli.ts b/apps/perstack/bin/cli.ts index bbac8327..9ccd7f76 100755 --- a/apps/perstack/bin/cli.ts +++ b/apps/perstack/bin/cli.ts @@ -4,12 +4,8 @@ import { Command } from "commander" import packageJson from "../package.json" with { type: "json" } import { installCommand } from "../src/install.js" import { logCommand } from "../src/log.js" -import { publishCommand } from "../src/publish.js" import { runCommand } from "../src/run.js" import { startCommand } from "../src/start.js" -import { statusCommand } from "../src/status.js" -import { tagCommand } from "../src/tag.js" -import { unpublishCommand } from "../src/unpublish.js" const program = new Command() .name(packageJson.name) @@ -19,8 +15,4 @@ const program = new Command() .addCommand(runCommand) .addCommand(logCommand) .addCommand(installCommand) - .addCommand(publishCommand) - .addCommand(unpublishCommand) - .addCommand(tagCommand) - .addCommand(statusCommand) program.parse() diff --git a/apps/perstack/package.json b/apps/perstack/package.json index 8b92eddb..c7b99fde 100644 --- a/apps/perstack/package.json +++ b/apps/perstack/package.json @@ -20,7 +20,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@perstack/api-client": "workspace:*", + "@perstack/api-client": "^0.0.51", "@perstack/core": "workspace:*", "@perstack/react": "workspace:*", "@perstack/runtime": "workspace:*", diff --git a/apps/perstack/src/install.ts b/apps/perstack/src/install.ts index c7e3ddcc..1014c4cd 100644 --- a/apps/perstack/src/install.ts +++ b/apps/perstack/src/install.ts @@ -1,6 +1,6 @@ import { readFile, writeFile } from "node:fs/promises" import path from "node:path" -import { createApiClient, type RegistryExpert } from "@perstack/api-client" +import { createApiClient } from "@perstack/api-client" import { defaultPerstackApiBaseUrl, type Expert, @@ -36,23 +36,115 @@ async function findConfigPathRecursively(cwd: string): Promise { } } -function toRuntimeExpert(key: string, expert: RegistryExpert): Expert { +type PublishedExpertData = { + name: string + version: string + description?: string + instruction: string + skills?: Record< + string, + | { + type: "mcpStdioSkill" + name: string + description: string + rule?: string + pick?: string[] + omit?: string[] + command: "npx" | "uvx" + packageName: string + requiredEnv?: string[] + } + | { + type: "mcpSseSkill" + name: string + description: string + rule?: string + pick?: string[] + omit?: string[] + endpoint: string + } + | { + type: "interactiveSkill" + name: string + description: string + rule?: string + tools: Record + } + > + delegates?: string[] + tags?: string[] +} + +function toRuntimeExpert(key: string, expert: PublishedExpertData): Expert { const skills: Record = Object.fromEntries( - Object.entries(expert.skills).map(([name, skill]) => { + Object.entries(expert.skills ?? {}).map(([name, skill]) => { switch (skill.type) { case "mcpStdioSkill": - return [name, { ...skill, name }] + return [ + name, + { + type: skill.type, + name, + description: skill.description, + rule: skill.rule, + pick: skill.pick ?? [], + omit: skill.omit ?? [], + command: skill.command, + packageName: skill.packageName, + requiredEnv: skill.requiredEnv ?? [], + lazyInit: false, + }, + ] case "mcpSseSkill": - return [name, { ...skill, name }] + return [ + name, + { + type: skill.type, + name, + description: skill.description, + rule: skill.rule, + pick: skill.pick ?? [], + omit: skill.omit ?? [], + endpoint: skill.endpoint, + lazyInit: false, + }, + ] case "interactiveSkill": - return [name, { ...skill, name }] + return [ + name, + { + type: skill.type, + name, + description: skill.description, + rule: skill.rule, + tools: Object.fromEntries( + Object.entries(skill.tools).map(([toolName, tool]) => [ + toolName, + { + name: toolName, + description: tool.description, + inputSchema: JSON.parse(tool.inputJsonSchema), + }, + ]), + ), + }, + ] default: { throw new Error(`Unknown skill type: ${(skill as { type: string }).type}`) } } }), ) - return { ...expert, key, skills } + return { + key, + name: expert.name, + version: expert.version, + description: expert.description ?? "", + instruction: expert.instruction, + skills, + delegates: expert.delegates ?? [], + tags: expert.tags ?? [], + } } function configExpertToExpert( @@ -79,10 +171,6 @@ async function resolveAllExperts( env: Record, ): Promise> { const experts: Record = {} - const client = createApiClient({ - baseUrl: config.perstackApiBaseUrl ?? defaultPerstackApiBaseUrl, - apiKey: env.PERSTACK_API_KEY, - }) for (const [key, configExpert] of Object.entries(config.experts ?? {})) { experts[key] = configExpertToExpert(key, configExpert) } @@ -94,18 +182,32 @@ async function resolveAllExperts( } } } + if (toResolve.size === 0) { + return experts + } + const apiKey = env.PERSTACK_API_KEY + if (!apiKey) { + throw new Error("PERSTACK_API_KEY is required to resolve remote delegates") + } + const client = createApiClient({ + baseUrl: config.perstackApiBaseUrl ?? defaultPerstackApiBaseUrl, + apiKey, + }) while (toResolve.size > 0) { const delegateKey = toResolve.values().next().value if (!delegateKey) break toResolve.delete(delegateKey) if (experts[delegateKey]) continue - const result = await client.registry.experts.get(delegateKey) + const result = await client.experts.get(delegateKey) if (!result.ok) { throw new Error(`Failed to resolve delegate "${delegateKey}": ${result.error.message}`) } - const registryExpert = result.data - experts[delegateKey] = toRuntimeExpert(delegateKey, registryExpert) - for (const nestedDelegate of registryExpert.delegates) { + const publishedExpert = result.data.data.definition.experts[delegateKey] + if (!publishedExpert) { + throw new Error(`Expert "${delegateKey}" not found in API response`) + } + experts[delegateKey] = toRuntimeExpert(delegateKey, publishedExpert) + for (const nestedDelegate of publishedExpert.delegates ?? []) { if (!experts[nestedDelegate]) { toResolve.add(nestedDelegate) } diff --git a/apps/perstack/src/publish.ts b/apps/perstack/src/publish.ts deleted file mode 100644 index 40fef1ce..00000000 --- a/apps/perstack/src/publish.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { type CreateExpertInput, createApiClient } from "@perstack/api-client" -import type { PerstackConfig } from "@perstack/core" -import { Command } from "commander" -import { getPerstackConfig } from "./lib/perstack-toml.js" -import { renderPublish } from "./tui/index.js" - -type ConfigSkills = NonNullable[string]["skills"]> -type ApiSkills = CreateExpertInput["skills"] - -function convertSkillsForApi(skills: ConfigSkills): ApiSkills { - return Object.fromEntries( - Object.entries(skills).map(([name, skill]) => { - if (skill.type === "mcpStdioSkill") { - const command = skill.command as "npx" | "uvx" - if (command !== "npx" && command !== "uvx") { - throw new Error( - `Invalid command "${skill.command}" for skill "${name}". Must be "npx" or "uvx".`, - ) - } - return [ - name, - { - type: "mcpStdioSkill" as const, - description: skill.description ?? `${name} skill`, - rule: skill.rule, - pick: skill.pick, - omit: skill.omit, - command, - packageName: skill.packageName ?? name, - requiredEnv: skill.requiredEnv, - }, - ] - } - if (skill.type === "mcpSseSkill") { - return [ - name, - { - type: "mcpSseSkill" as const, - description: skill.description ?? `${name} skill`, - rule: skill.rule, - pick: skill.pick, - omit: skill.omit, - endpoint: skill.endpoint, - }, - ] - } - return [ - name, - { - type: "interactiveSkill" as const, - description: skill.description ?? `${name} skill`, - rule: skill.rule, - tools: Object.fromEntries( - Object.entries(skill.tools).map(([toolName, tool]) => [ - toolName, - { - description: tool.description ?? `${toolName} tool`, - inputJsonSchema: tool.inputJsonSchema, - }, - ]), - ), - }, - ] - }), - ) -} - -export const publishCommand = new Command() - .command("publish") - .description("Publish an Expert to the registry") - .argument("[expertName]", "Expert name to publish (prompts if not provided)") - .option("--config ", "Path to perstack.toml config file") - .option("--dry-run", "Validate without publishing") - .action( - async (expertName: string | undefined, options: { config?: string; dryRun?: boolean }) => { - try { - const perstackConfig = await getPerstackConfig(options.config) - const experts = perstackConfig.experts - if (!experts || Object.keys(experts).length === 0) { - console.error("No experts defined in perstack.toml") - process.exit(1) - } - const expertNames = Object.keys(experts) - let selectedExpert: string - if (expertName) { - if (!experts[expertName]) { - console.error(`Expert "${expertName}" not found in perstack.toml`) - console.error(`Available experts: ${expertNames.join(", ")}`) - process.exit(1) - } - selectedExpert = expertName - } else { - if (expertNames.length === 1) { - selectedExpert = expertNames[0] - } else { - const result = await renderPublish({ - experts: expertNames.map((name) => ({ - name, - description: experts[name].description, - })), - }) - if (!result) { - console.log("Cancelled") - process.exit(0) - } - selectedExpert = result - } - } - const expert = experts[selectedExpert] - const version = expert.version ?? "1.0.0" - const payload: CreateExpertInput = { - name: selectedExpert, - version, - minRuntimeVersion: "v1.0", - description: expert.description ?? "", - instruction: expert.instruction, - skills: convertSkillsForApi(expert.skills ?? {}), - delegates: expert.delegates ?? [], - tags: ["latest"], - } - if (options.dryRun) { - console.log("Dry run - would publish:") - console.log(JSON.stringify(payload, null, 2)) - return - } - const client = createApiClient({ - baseUrl: perstackConfig.perstackApiBaseUrl, - apiKey: process.env.PERSTACK_API_KEY, - }) - const result = await client.registry.experts.create(payload) - if (!result.ok) { - throw new Error(result.error.message) - } - console.log(`Published ${result.data.key}`) - } catch (error) { - if (error instanceof Error) { - console.error(error.message) - } else { - console.error(error) - } - process.exit(1) - } - }, - ) diff --git a/apps/perstack/src/status.ts b/apps/perstack/src/status.ts deleted file mode 100644 index 738628d2..00000000 --- a/apps/perstack/src/status.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { createApiClient } from "@perstack/api-client" -import { Command } from "commander" -import { getPerstackConfig } from "./lib/perstack-toml.js" -import { renderStatus, type WizardVersionInfo } from "./tui/index.js" - -export const statusCommand = new Command() - .command("status") - .description("Change the status of an Expert version") - .argument("[expertKey]", "Expert key with version (e.g., my-expert@1.0.0)") - .argument("[status]", "New status (available, deprecated, disabled)") - .option("--config ", "Path to perstack.toml config file") - .action( - async ( - expertKey: string | undefined, - status: string | undefined, - options: { config?: string }, - ) => { - try { - const perstackConfig = await getPerstackConfig(options.config) - const client = createApiClient({ - baseUrl: perstackConfig.perstackApiBaseUrl, - apiKey: process.env.PERSTACK_API_KEY, - }) - if (!expertKey) { - const experts = perstackConfig.experts - if (!experts || Object.keys(experts).length === 0) { - console.error("No experts defined in perstack.toml") - process.exit(1) - } - const expertNames = Object.keys(experts) - const result = await renderStatus({ - experts: expertNames.map((name) => ({ - name, - description: experts[name].description, - })), - onFetchVersions: async (expertName: string): Promise => { - const versionsResult = await client.registry.experts.getVersions(expertName) - if (!versionsResult.ok) { - throw new Error(`Expert "${expertName}" not found in registry`) - } - const { versions } = versionsResult.data - const versionInfos: WizardVersionInfo[] = [] - for (const v of versions) { - const expertResult = await client.registry.experts.get(v.key) - if (expertResult.ok && expertResult.data.type === "registryExpert") { - versionInfos.push({ - key: v.key, - version: v.version ?? "unknown", - tags: v.tags, - status: expertResult.data.status, - }) - } else { - versionInfos.push({ - key: v.key, - version: v.version ?? "unknown", - tags: v.tags, - status: "available", - }) - } - } - return versionInfos - }, - }) - if (!result) { - console.log("Cancelled") - process.exit(0) - } - const updateResult = await client.registry.experts.update(result.expertKey, { - status: result.status, - }) - if (!updateResult.ok) { - throw new Error(updateResult.error.message) - } - console.log(`Updated ${updateResult.data.key}`) - console.log(` Status: ${updateResult.data.status}`) - return - } - if (!expertKey.includes("@")) { - console.error("Expert key must include version (e.g., my-expert@1.0.0)") - process.exit(1) - } - if (!status) { - console.error("Please provide a status (available, deprecated, disabled)") - process.exit(1) - } - if (!["available", "deprecated", "disabled"].includes(status)) { - console.error("Invalid status. Must be: available, deprecated, or disabled") - process.exit(1) - } - const updateResult = await client.registry.experts.update(expertKey, { - status: status as "available" | "deprecated" | "disabled", - }) - if (!updateResult.ok) { - throw new Error(updateResult.error.message) - } - console.log(`Updated ${updateResult.data.key}`) - console.log(` Status: ${updateResult.data.status}`) - } catch (error) { - if (error instanceof Error) { - console.error(error.message) - } else { - console.error(error) - } - process.exit(1) - } - }, - ) diff --git a/apps/perstack/src/tag.ts b/apps/perstack/src/tag.ts deleted file mode 100644 index 2e0c6d65..00000000 --- a/apps/perstack/src/tag.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { createApiClient } from "@perstack/api-client" -import { Command } from "commander" -import { getPerstackConfig } from "./lib/perstack-toml.js" -import { renderTag, type WizardVersionInfo } from "./tui/index.js" - -export const tagCommand = new Command() - .command("tag") - .description("Add or update tags on an Expert version") - .argument("[expertKey]", "Expert key with version (e.g., my-expert@1.0.0)") - .argument("[tags...]", "Tags to set (e.g., stable beta)") - .option("--config ", "Path to perstack.toml config file") - .action( - async ( - expertKey: string | undefined, - tags: string[] | undefined, - options: { config?: string }, - ) => { - try { - const perstackConfig = await getPerstackConfig(options.config) - const client = createApiClient({ - baseUrl: perstackConfig.perstackApiBaseUrl, - apiKey: process.env.PERSTACK_API_KEY, - }) - if (!expertKey) { - const experts = perstackConfig.experts - if (!experts || Object.keys(experts).length === 0) { - console.error("No experts defined in perstack.toml") - process.exit(1) - } - const expertNames = Object.keys(experts) - const result = await renderTag({ - experts: expertNames.map((name) => ({ - name, - description: experts[name].description, - })), - onFetchVersions: async (expertName: string): Promise => { - const versionsResult = await client.registry.experts.getVersions(expertName) - if (!versionsResult.ok) { - throw new Error(`Expert "${expertName}" not found in registry`) - } - const { versions } = versionsResult.data - const versionInfos: WizardVersionInfo[] = [] - for (const v of versions) { - const expertResult = await client.registry.experts.get(v.key) - if (expertResult.ok && expertResult.data.type === "registryExpert") { - versionInfos.push({ - key: v.key, - version: v.version ?? "unknown", - tags: v.tags, - status: expertResult.data.status, - }) - } else { - versionInfos.push({ - key: v.key, - version: v.version ?? "unknown", - tags: v.tags, - status: "available", - }) - } - } - return versionInfos - }, - }) - if (!result) { - console.log("Cancelled") - process.exit(0) - } - const updateResult = await client.registry.experts.update(result.expertKey, { - tags: result.tags, - }) - if (!updateResult.ok) { - throw new Error(updateResult.error.message) - } - console.log(`Updated ${updateResult.data.key}`) - console.log( - ` Tags: ${updateResult.data.tags.length > 0 ? updateResult.data.tags.join(", ") : "(none)"}`, - ) - return - } - if (!expertKey.includes("@")) { - console.error("Expert key must include version (e.g., my-expert@1.0.0)") - process.exit(1) - } - if (!tags || tags.length === 0) { - console.error("Please provide tags to set") - process.exit(1) - } - const updateResult = await client.registry.experts.update(expertKey, { tags }) - if (!updateResult.ok) { - throw new Error(updateResult.error.message) - } - console.log(`Updated ${updateResult.data.key}`) - console.log( - ` Tags: ${updateResult.data.tags.length > 0 ? updateResult.data.tags.join(", ") : "(none)"}`, - ) - } catch (error) { - if (error instanceof Error) { - console.error(error.message) - } else { - console.error(error) - } - process.exit(1) - } - }, - ) diff --git a/apps/perstack/src/tui/components/action-row.tsx b/apps/perstack/src/tui/components/action-row.tsx index 34dff4b1..3752e33d 100644 --- a/apps/perstack/src/tui/components/action-row.tsx +++ b/apps/perstack/src/tui/components/action-row.tsx @@ -3,59 +3,6 @@ import type React from "react" import { INDICATOR } from "../constants.js" export type StatusColor = "green" | "red" | "yellow" | "white" | "gray" | "cyan" | "blue" -type QueryRowProps = { - text: string -} -export const QueryRow = ({ text }: QueryRowProps) => ( - - - {INDICATOR.CHEVRON_RIGHT} - - {text} - -) - -type CompletionRowProps = { - text: string -} -export const CompletionRow = ({ text }: CompletionRowProps) => ( - - - {INDICATOR.BULLET} - Run Results - - - - {INDICATOR.TREE} - - {text} - - -) - -type ErrorRowProps = { - errorName: string - message: string - statusCode?: number -} -export const ErrorRow = ({ errorName, message, statusCode }: ErrorRowProps) => ( - - - {INDICATOR.BULLET} - - Error: {errorName} - {statusCode ? ` (${statusCode})` : ""} - - - - - {INDICATOR.TREE} - - {message} - - -) - type ActionRowSimpleProps = { indicatorColor: StatusColor text: string diff --git a/apps/perstack/src/tui/components/error-step.tsx b/apps/perstack/src/tui/components/error-step.tsx deleted file mode 100644 index da0c51f4..00000000 --- a/apps/perstack/src/tui/components/error-step.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Box, Text, useInput } from "ink" - -type ErrorStepProps = { - message: string - onBack: () => void -} - -export function ErrorStep({ message, onBack }: ErrorStepProps) { - useInput(() => onBack()) - return ( - - Error: {message} - - Press any key to go back - - - ) -} - -export type { ErrorStepProps } diff --git a/apps/perstack/src/tui/components/index.ts b/apps/perstack/src/tui/components/index.ts index 93fad761..5b680271 100644 --- a/apps/perstack/src/tui/components/index.ts +++ b/apps/perstack/src/tui/components/index.ts @@ -1,13 +1,7 @@ export { BrowserRouter } from "./browser-router.js" export { CheckpointActionRow } from "./checkpoint-action-row.js" -export { ErrorStep, type ErrorStepProps } from "./error-step.js" export { ExpertList, type ExpertListProps } from "./expert-list.js" export { ExpertSelectorBase, type ExpertSelectorBaseProps } from "./expert-selector-base.js" export { ListBrowser, type ListBrowserProps } from "./list-browser.js" export { RunSetting, type RunSettingProps } from "./run-setting.js" export { StreamingDisplay } from "./streaming-display.js" -export { VersionSelector, type VersionSelectorProps } from "./version-selector.js" -export { - WizardExpertSelector, - type WizardExpertSelectorProps, -} from "./wizard-expert-selector.js" diff --git a/apps/perstack/src/tui/components/version-selector.tsx b/apps/perstack/src/tui/components/version-selector.tsx deleted file mode 100644 index 1e9154b2..00000000 --- a/apps/perstack/src/tui/components/version-selector.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { Box, Text, useApp, useInput } from "ink" -import { useState } from "react" -import type { WizardVersionInfo } from "../types/wizard.js" -import { getStatusColor } from "../utils/status-color.js" - -export type VersionSelectorProps = { - expertName: string - versions: WizardVersionInfo[] - onSelect: (version: WizardVersionInfo) => void - onBack: () => void - title?: string -} - -export function VersionSelector({ - expertName, - versions, - onSelect, - onBack, - title, -}: VersionSelectorProps) { - const { exit } = useApp() - const [selectedIndex, setSelectedIndex] = useState(0) - useInput((input, key) => { - if (key.upArrow) { - setSelectedIndex((prev) => (prev > 0 ? prev - 1 : versions.length - 1)) - } else if (key.downArrow) { - setSelectedIndex((prev) => (prev < versions.length - 1 ? prev + 1 : 0)) - } else if (key.return) { - const version = versions[selectedIndex] - if (version) { - onSelect(version) - } - } else if (key.escape || key.backspace || key.delete) { - onBack() - } else if (input === "q") { - exit() - } - }) - return ( - - {title ?? `Select a version of ${expertName}:`} - - {versions.map((v, index) => ( - - - {index === selectedIndex ? "❯ " : " "} - {v.version} - {v.tags.length > 0 && [{v.tags.join(", ")}]} - {v.status} - - - ))} - - - ↑↓ navigate · enter select · esc back · q quit - - - ) -} diff --git a/apps/perstack/src/tui/components/wizard-expert-selector.tsx b/apps/perstack/src/tui/components/wizard-expert-selector.tsx deleted file mode 100644 index fdc90e52..00000000 --- a/apps/perstack/src/tui/components/wizard-expert-selector.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { SelectableList } from "@perstack/tui-components" -import { Box, Text, useApp, useInput } from "ink" -import { useState } from "react" -import type { WizardExpertChoice } from "../types/wizard.js" - -export type WizardExpertSelectorProps = { - title: string - experts: WizardExpertChoice[] - onSelect: (name: string) => void -} -export function WizardExpertSelector({ title, experts, onSelect }: WizardExpertSelectorProps) { - const { exit } = useApp() - const [selectedIndex, setSelectedIndex] = useState(0) - const expertItems = experts.map((expert) => ({ - key: expert.name, - expert, - })) - useInput((input, key) => { - if (key.upArrow) { - setSelectedIndex((prev) => (prev > 0 ? prev - 1 : experts.length - 1)) - } else if (key.downArrow) { - setSelectedIndex((prev) => (prev < experts.length - 1 ? prev + 1 : 0)) - } else if (key.return) { - const expert = experts[selectedIndex] - if (expert) { - onSelect(expert.name) - } - } else if (input === "q" || key.escape) { - exit() - } - }) - return ( - - {title} - - ( - - {isSelected ? "❯ " : " "} - {item.expert.name} - {item.expert.description && - {item.expert.description}} - - )} - /> - - - ↑↓ navigate · enter select · q quit - - - ) -} diff --git a/apps/perstack/src/tui/hooks/index.ts b/apps/perstack/src/tui/hooks/index.ts index af300cf6..f386fcf9 100644 --- a/apps/perstack/src/tui/hooks/index.ts +++ b/apps/perstack/src/tui/hooks/index.ts @@ -1,4 +1,3 @@ export { useErrorHandler } from "./core/index.js" export { type InputAction, useInputState, useRun, useRuntimeInfo } from "./state/index.js" export { useExpertSelector } from "./ui/index.js" -export { type BaseWizardStep, useExpertVersionWizard } from "./wizard/index.js" diff --git a/apps/perstack/src/tui/hooks/wizard/index.ts b/apps/perstack/src/tui/hooks/wizard/index.ts deleted file mode 100644 index cb5b1bc1..00000000 --- a/apps/perstack/src/tui/hooks/wizard/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { type BaseWizardStep, useExpertVersionWizard } from "./use-expert-version-wizard.js" diff --git a/apps/perstack/src/tui/hooks/wizard/use-expert-version-wizard.tsx b/apps/perstack/src/tui/hooks/wizard/use-expert-version-wizard.tsx deleted file mode 100644 index 6bd049af..00000000 --- a/apps/perstack/src/tui/hooks/wizard/use-expert-version-wizard.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { Box, Text } from "ink" -import { useState } from "react" -import { ErrorStep, VersionSelector, WizardExpertSelector } from "../../components/index.js" -import type { WizardExpertChoice, WizardVersionInfo } from "../../types/wizard.js" - -export type BaseWizardStep = - | { type: "selectExpert" } - | { type: "loadingVersions"; expertName: string } - | { type: "selectVersion"; expertName: string; versions: WizardVersionInfo[] } - | { type: "error"; message: string } - -function isBaseStep(step: BaseWizardStep | unknown): step is BaseWizardStep { - if (typeof step !== "object" || step === null) return false - const stepType = (step as { type: string }).type - return ( - stepType === "selectExpert" || - stepType === "loadingVersions" || - stepType === "selectVersion" || - stepType === "error" - ) -} - -type WizardOptions = { - experts: WizardExpertChoice[] - onFetchVersions: (expertName: string) => Promise - customStepFromVersion: (version: WizardVersionInfo) => TCustomStep -} - -type WizardHandlers = { - handleExpertSelect: (expertName: string) => Promise - handleVersionSelect: (version: WizardVersionInfo) => void - handleBack: (customBackHandler?: (currentStep: TCustomStep) => void) => void -} - -type WizardRenderOptions = { - title?: string - versionSelectorTitle?: string -} - -export function useExpertVersionWizard({ - experts, - onFetchVersions, - customStepFromVersion, -}: WizardOptions) { - const [step, setStep] = useState({ type: "selectExpert" }) - - const handleExpertSelect = async (expertName: string) => { - setStep({ type: "loadingVersions", expertName }) - try { - const versions = await onFetchVersions(expertName) - if (versions.length === 0) { - setStep({ type: "error", message: `No versions found for ${expertName}` }) - return - } - setStep({ type: "selectVersion", expertName, versions }) - } catch (error) { - setStep({ - type: "error", - message: error instanceof Error ? error.message : "Failed to fetch versions", - }) - } - } - - const handleVersionSelect = (version: WizardVersionInfo) => { - const customStep = customStepFromVersion(version) - setStep(customStep) - } - - const handleBack = (customBackHandler?: (currentStep: TCustomStep) => void) => { - if (isBaseStep(step)) { - switch (step.type) { - case "selectVersion": - setStep({ type: "selectExpert" }) - break - case "error": - setStep({ type: "selectExpert" }) - break - default: - // selectExpert or loadingVersions - do nothing, let parent handle - break - } - } else if (customBackHandler) { - customBackHandler(step as TCustomStep) - } - } - - const renderBaseStep = (options?: WizardRenderOptions) => { - if (!isBaseStep(step)) { - return null - } - - switch (step.type) { - case "selectExpert": - return ( - - ) - case "loadingVersions": - return ( - - Loading versions for {step.expertName}... - - ) - case "selectVersion": - return ( - - ) - case "error": - return - default: - return null - } - } - - const handlers: WizardHandlers = { - handleExpertSelect, - handleVersionSelect, - handleBack, - } - - return { - step, - setStep, - handlers, - renderBaseStep, - isBaseStep: isBaseStep(step), - } -} diff --git a/apps/perstack/src/tui/index.ts b/apps/perstack/src/tui/index.ts index 80cb8135..bb43ab97 100644 --- a/apps/perstack/src/tui/index.ts +++ b/apps/perstack/src/tui/index.ts @@ -1,16 +1,9 @@ export { type ExecutionParams, type ExecutionResult, renderExecution } from "./execution/index.js" export { type ProgressHandle, renderProgress } from "./progress/render.js" -export { renderPublish } from "./publish/render.js" export { renderSelection, type SelectionParams, type SelectionResult } from "./selection/index.js" -export { renderStatus, type StatusWizardResult } from "./status/render.js" -export { renderTag, type TagWizardResult } from "./tag/render.js" export type { CheckpointHistoryItem, EventHistoryItem, JobHistoryItem, PerstackEvent, - RunHistoryItem, - WizardExpertChoice, - WizardVersionInfo, } from "./types/index.js" -export { renderUnpublish, type UnpublishWizardResult } from "./unpublish/render.js" diff --git a/apps/perstack/src/tui/publish/app.tsx b/apps/perstack/src/tui/publish/app.tsx deleted file mode 100644 index 6c6c2581..00000000 --- a/apps/perstack/src/tui/publish/app.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useApp } from "ink" -import { WizardExpertSelector } from "../components/index.js" -import type { WizardExpertChoice } from "../types/wizard.js" - -type PublishAppProps = { - experts: WizardExpertChoice[] - onSelect: (expertName: string) => void -} -export function PublishApp({ experts, onSelect }: PublishAppProps) { - const { exit } = useApp() - const handleSelect = (name: string) => { - onSelect(name) - exit() - } - return ( - - ) -} diff --git a/apps/perstack/src/tui/publish/render.tsx b/apps/perstack/src/tui/publish/render.tsx deleted file mode 100644 index b81fec50..00000000 --- a/apps/perstack/src/tui/publish/render.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { render } from "ink" -import type { WizardExpertChoice } from "../types/wizard.js" -import { PublishApp } from "./app.js" - -type RenderPublishSelectOptions = { - experts: WizardExpertChoice[] -} - -export async function renderPublish(options: RenderPublishSelectOptions): Promise { - return new Promise((resolve) => { - let selected: string | null = null - const { waitUntilExit } = render( - { - selected = expertName - }} - />, - ) - waitUntilExit().then(() => { - resolve(selected) - }) - }) -} diff --git a/apps/perstack/src/tui/status/app.tsx b/apps/perstack/src/tui/status/app.tsx deleted file mode 100644 index 08c4733f..00000000 --- a/apps/perstack/src/tui/status/app.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import { Box, Text, useApp, useInput } from "ink" -import { useState } from "react" -import { KEY_HINTS } from "../constants.js" -import { useExpertVersionWizard } from "../hooks/index.js" -import type { WizardExpertChoice, WizardVersionInfo } from "../types/wizard.js" -import { getStatusColor } from "../utils/index.js" - -type CustomWizardStep = - | { type: "selectStatus"; expertKey: string; currentStatus: string } - | { type: "confirm"; expertKey: string; status: string; currentStatus: string } - -type StatusWizardResult = { - expertKey: string - status: "available" | "deprecated" | "disabled" -} - -type StatusAppProps = { - experts: WizardExpertChoice[] - onFetchVersions: (expertName: string) => Promise - onComplete: (result: StatusWizardResult) => void - onCancel: () => void -} - -function getAvailableStatusTransitions(currentStatus: string): string[] { - switch (currentStatus) { - case "available": - return ["available", "deprecated", "disabled"] - case "deprecated": - return ["deprecated", "disabled"] - case "disabled": - return ["disabled"] - default: - return [currentStatus] - } -} - -function StatusSelector({ - expertKey, - currentStatus, - onSelect, - onBack, -}: { - expertKey: string - currentStatus: string - onSelect: (status: string) => void - onBack: () => void -}) { - const { exit } = useApp() - const availableStatuses = getAvailableStatusTransitions(currentStatus) - const [selectedIndex, setSelectedIndex] = useState(0) - useInput((input, key) => { - if (key.upArrow) { - setSelectedIndex((prev) => (prev > 0 ? prev - 1 : availableStatuses.length - 1)) - } else if (key.downArrow) { - setSelectedIndex((prev) => (prev < availableStatuses.length - 1 ? prev + 1 : 0)) - } else if (key.return) { - const status = availableStatuses[selectedIndex] - if (status) { - onSelect(status) - } - } else if (key.escape) { - onBack() - } else if (input === "q") { - exit() - } - }) - return ( - - Select status for {expertKey}: - Current: {currentStatus} - - {availableStatuses.map((status, index) => ( - - - {index === selectedIndex ? "❯ " : " "} - {status} - {status === currentStatus && (current)} - - - ))} - - {currentStatus === "disabled" && ( - - ⚠ disabled status cannot be changed - - )} - - - {KEY_HINTS.NAVIGATE} {KEY_HINTS.SELECT} {KEY_HINTS.ESC_BACK} {KEY_HINTS.QUIT} - - - - ) -} - -function ConfirmStep({ - expertKey, - status, - currentStatus, - onConfirm, - onBack, -}: { - expertKey: string - status: string - currentStatus: string - onConfirm: () => void - onBack: () => void -}) { - const { exit } = useApp() - const [selectedIndex, setSelectedIndex] = useState(0) - const options = ["Confirm", "Cancel"] - useInput((input, key) => { - if (key.upArrow || key.downArrow) { - setSelectedIndex((prev) => (prev === 0 ? 1 : 0)) - } else if (key.return) { - if (selectedIndex === 0) { - onConfirm() - } else { - onBack() - } - } else if (key.escape) { - onBack() - } else if (input === "q") { - exit() - } - }) - const statusChanged = status !== currentStatus - return ( - - Confirm status change for {expertKey}: - - {statusChanged ? ( - - Status: {currentStatus} - - {status} - - ) : ( - - Status: {status} - (unchanged) - - )} - - - {options.map((option, index) => ( - - {index === selectedIndex ? "❯ " : " "} - {option} - - ))} - - - - {KEY_HINTS.NAVIGATE} {KEY_HINTS.SELECT} {KEY_HINTS.ESC_BACK} {KEY_HINTS.QUIT} - - - - ) -} - -export function StatusApp({ experts, onFetchVersions, onComplete, onCancel }: StatusAppProps) { - const { exit } = useApp() - - const { step, setStep, handlers, renderBaseStep, isBaseStep } = - useExpertVersionWizard({ - experts, - onFetchVersions, - customStepFromVersion: (version) => ({ - type: "selectStatus", - expertKey: version.key, - currentStatus: version.status, - }), - }) - - const handleStatusSelect = (status: string) => { - if (step.type === "selectStatus") { - setStep({ - type: "confirm", - expertKey: step.expertKey, - status, - currentStatus: step.currentStatus, - }) - } - } - - const handleConfirm = () => { - if (step.type === "confirm") { - onComplete({ - expertKey: step.expertKey, - status: step.status as "available" | "deprecated" | "disabled", - }) - exit() - } - } - - const handleCustomBack = (currentStep: CustomWizardStep) => { - switch (currentStep.type) { - case "selectStatus": - setStep({ type: "selectExpert" }) - break - case "confirm": - setStep({ - type: "selectStatus", - expertKey: currentStep.expertKey, - currentStatus: currentStep.currentStatus, - }) - break - default: - onCancel() - exit() - } - } - - // Render base steps (selectExpert, loadingVersions, selectVersion, error) - const baseStepRender = renderBaseStep({ title: "Select an Expert to change status:" }) - if (baseStepRender) { - return baseStepRender - } - - // Render custom steps - if (!isBaseStep) { - const customStep = step as CustomWizardStep - switch (customStep.type) { - case "selectStatus": - return ( - handlers.handleBack(handleCustomBack)} - /> - ) - case "confirm": - return ( - handlers.handleBack(handleCustomBack)} - /> - ) - } - } - - return null -} - -export type { StatusWizardResult } -export type { WizardExpertChoice, WizardVersionInfo } from "../types/wizard.js" diff --git a/apps/perstack/src/tui/status/render.tsx b/apps/perstack/src/tui/status/render.tsx deleted file mode 100644 index 53ec421c..00000000 --- a/apps/perstack/src/tui/status/render.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { render } from "ink" -import type { WizardExpertChoice, WizardVersionInfo } from "../types/wizard.js" -import { StatusApp, type StatusWizardResult } from "./app.js" - -type RenderStatusWizardOptions = { - experts: WizardExpertChoice[] - onFetchVersions: (expertName: string) => Promise -} - -export async function renderStatus( - options: RenderStatusWizardOptions, -): Promise { - return new Promise((resolve) => { - let result: StatusWizardResult | null = null - const { waitUntilExit } = render( - { - result = r - }} - onCancel={() => { - result = null - }} - />, - ) - waitUntilExit().then(() => { - resolve(result) - }) - }) -} - -export type { StatusWizardResult } -export type { WizardExpertChoice, WizardVersionInfo } from "../types/wizard.js" diff --git a/apps/perstack/src/tui/tag/app.tsx b/apps/perstack/src/tui/tag/app.tsx deleted file mode 100644 index cfe52d6f..00000000 --- a/apps/perstack/src/tui/tag/app.tsx +++ /dev/null @@ -1,250 +0,0 @@ -import { Box, Text, useApp, useInput } from "ink" -import { useState } from "react" -import { KEY_HINTS } from "../constants.js" -import { useExpertVersionWizard } from "../hooks/index.js" -import type { WizardExpertChoice, WizardVersionInfo } from "../types/wizard.js" - -type CustomWizardStep = - | { type: "inputTags"; expertKey: string; currentTags: string[] } - | { type: "confirm"; expertKey: string; tags: string[]; currentTags: string[] } - -type TagWizardResult = { - expertKey: string - tags: string[] -} - -type TagAppProps = { - experts: WizardExpertChoice[] - onFetchVersions: (expertName: string) => Promise - onComplete: (result: TagWizardResult) => void - onCancel: () => void -} - -function TagInput({ - expertKey, - currentTags, - onSubmit, - onBack, -}: { - expertKey: string - currentTags: string[] - onSubmit: (tags: string[]) => void - onBack: () => void -}) { - const { exit } = useApp() - const customTags = currentTags.filter((t) => t !== "latest") - const [input, setInput] = useState(customTags.join(", ")) - const [warning, setWarning] = useState("") - useInput((char, key) => { - if (key.return) { - const rawTags = input - .split(",") - .map((t) => t.trim()) - .filter((t) => t.length > 0) - const hasLatest = rawTags.includes("latest") - const tags = rawTags.filter((t) => t !== "latest") - if (hasLatest) { - setWarning("'latest' tag is managed automatically and was removed") - setInput(tags.join(", ")) - return - } - setWarning("") - onSubmit(tags) - } else if (key.escape) { - onBack() - } else if (key.backspace || key.delete) { - setInput((prev) => prev.slice(0, -1)) - setWarning("") - } else if (char === "q" && key.ctrl) { - exit() - } else if (char && !key.ctrl && !key.meta) { - setInput((prev) => prev + char) - setWarning("") - } - }) - const hasLatestInCurrent = currentTags.includes("latest") - return ( - - Enter tags for {expertKey}: - - Current: {customTags.length > 0 ? customTags.join(", ") : "(none)"} - {hasLatestInCurrent && [latest - auto-managed]} - - - Tags: - {input} - - - {warning && ( - - ⚠ {warning} - - )} - - - comma-separated {KEY_HINTS.CONFIRM} {KEY_HINTS.ESC_BACK} {KEY_HINTS.CTRL_QUIT} - - - - ) -} - -function ConfirmStep({ - expertKey, - tags, - currentTags, - onConfirm, - onBack, -}: { - expertKey: string - tags: string[] - currentTags: string[] - onConfirm: () => void - onBack: () => void -}) { - const { exit } = useApp() - const [selectedIndex, setSelectedIndex] = useState(0) - const options = ["Confirm", "Cancel"] - useInput((input, key) => { - if (key.upArrow || key.downArrow) { - setSelectedIndex((prev) => (prev === 0 ? 1 : 0)) - } else if (key.return) { - if (selectedIndex === 0) { - onConfirm() - } else { - onBack() - } - } else if (key.escape) { - onBack() - } else if (input === "q") { - exit() - } - }) - const customCurrentTags = currentTags.filter((t) => t !== "latest") - const tagsChanged = - tags.length !== customCurrentTags.length || - [...tags].sort().join(",") !== [...customCurrentTags].sort().join(",") - return ( - - Confirm update for {expertKey}: - - {tagsChanged ? ( - - Tags: {customCurrentTags.join(", ") || "(none)"} - - {tags.join(", ") || "(none)"} - - ) : ( - - Tags: {tags.join(", ") || "(none)"} - (unchanged) - - )} - - - {options.map((option, index) => ( - - {index === selectedIndex ? "❯ " : " "} - {option} - - ))} - - - - {KEY_HINTS.NAVIGATE} {KEY_HINTS.SELECT} {KEY_HINTS.ESC_BACK} {KEY_HINTS.QUIT} - - - - ) -} - -export function TagApp({ experts, onFetchVersions, onComplete, onCancel }: TagAppProps) { - const { exit } = useApp() - - const { step, setStep, handlers, renderBaseStep, isBaseStep } = - useExpertVersionWizard({ - experts, - onFetchVersions, - customStepFromVersion: (version) => ({ - type: "inputTags", - expertKey: version.key, - currentTags: version.tags, - }), - }) - - const handleTagsSubmit = (tags: string[]) => { - if (step.type === "inputTags") { - setStep({ - type: "confirm", - expertKey: step.expertKey, - tags, - currentTags: step.currentTags, - }) - } - } - - const handleConfirm = () => { - if (step.type === "confirm") { - onComplete({ - expertKey: step.expertKey, - tags: step.tags, - }) - exit() - } - } - - const handleCustomBack = (currentStep: CustomWizardStep) => { - switch (currentStep.type) { - case "inputTags": - setStep({ type: "selectExpert" }) - break - case "confirm": - setStep({ - type: "inputTags", - expertKey: currentStep.expertKey, - currentTags: currentStep.currentTags, - }) - break - default: - onCancel() - exit() - } - } - - // Render base steps (selectExpert, loadingVersions, selectVersion, error) - const baseStepRender = renderBaseStep({ title: "Select an Expert to tag:" }) - if (baseStepRender) { - return baseStepRender - } - - // Render custom steps - if (!isBaseStep) { - const customStep = step as CustomWizardStep - switch (customStep.type) { - case "inputTags": - return ( - handlers.handleBack(handleCustomBack)} - /> - ) - case "confirm": - return ( - handlers.handleBack(handleCustomBack)} - /> - ) - } - } - - return null -} - -export type { TagWizardResult } -export type { WizardExpertChoice, WizardVersionInfo } from "../types/wizard.js" diff --git a/apps/perstack/src/tui/tag/render.tsx b/apps/perstack/src/tui/tag/render.tsx deleted file mode 100644 index c61049b3..00000000 --- a/apps/perstack/src/tui/tag/render.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { render } from "ink" -import type { WizardExpertChoice, WizardVersionInfo } from "../types/wizard.js" -import { TagApp, type TagWizardResult } from "./app.js" - -type RenderTagWizardOptions = { - experts: WizardExpertChoice[] - onFetchVersions: (expertName: string) => Promise -} - -export async function renderTag(options: RenderTagWizardOptions): Promise { - return new Promise((resolve) => { - let result: TagWizardResult | null = null - const { waitUntilExit } = render( - { - result = r - }} - onCancel={() => { - result = null - }} - />, - ) - waitUntilExit().then(() => { - resolve(result) - }) - }) -} - -export type { TagWizardResult } -export type { WizardExpertChoice, WizardVersionInfo } from "../types/wizard.js" diff --git a/apps/perstack/src/tui/types/base.ts b/apps/perstack/src/tui/types/base.ts index b1089234..14908694 100644 --- a/apps/perstack/src/tui/types/base.ts +++ b/apps/perstack/src/tui/types/base.ts @@ -1,7 +1,6 @@ export type { Activity, PerstackEvent } from "@perstack/core" -export type { PerRunStreamingState, RuntimeState, StreamingState } from "@perstack/react" +export type { PerRunStreamingState, StreamingState } from "@perstack/react" -export type EventResult = { initialized?: boolean; completed?: boolean; stopped?: boolean } export type RuntimeInfo = { runtimeVersion?: string expertName?: string @@ -35,15 +34,6 @@ export type ExpertOption = { lastUsed?: number source?: "configured" | "recent" } -export type RunHistoryItem = { - jobId: string - runId: string - expertKey: string - model: string - inputText: string - startedAt: number - updatedAt: number -} export type JobHistoryItem = { jobId: string status: string diff --git a/apps/perstack/src/tui/types/index.ts b/apps/perstack/src/tui/types/index.ts index feab0370..b30e3266 100644 --- a/apps/perstack/src/tui/types/index.ts +++ b/apps/perstack/src/tui/types/index.ts @@ -2,15 +2,12 @@ export type { Activity, CheckpointHistoryItem, EventHistoryItem, - EventResult, ExpertOption, InitialRuntimeConfig, JobHistoryItem, PerRunStreamingState, PerstackEvent, - RunHistoryItem, RuntimeInfo, - RuntimeState, StreamingState, } from "./base.js" export type { @@ -23,4 +20,3 @@ export type { InputState, RunningState, } from "./input-state.js" -export type { WizardExpertChoice, WizardVersionInfo } from "./wizard.js" diff --git a/apps/perstack/src/tui/types/wizard.ts b/apps/perstack/src/tui/types/wizard.ts deleted file mode 100644 index 3174b0e8..00000000 --- a/apps/perstack/src/tui/types/wizard.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type WizardExpertChoice = { - name: string - description?: string -} -export type WizardVersionInfo = { - key: string - version: string - tags: string[] - status: "available" | "deprecated" | "disabled" -} diff --git a/apps/perstack/src/tui/unpublish/app.tsx b/apps/perstack/src/tui/unpublish/app.tsx deleted file mode 100644 index 36a3f84c..00000000 --- a/apps/perstack/src/tui/unpublish/app.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { Box, Text, useApp, useInput } from "ink" -import { useState } from "react" -import { KEY_HINTS } from "../constants.js" -import { useExpertVersionWizard } from "../hooks/index.js" -import type { WizardExpertChoice, WizardVersionInfo } from "../types/wizard.js" - -type CustomWizardStep = { - type: "confirm" - expertKey: string - version: string -} - -type UnpublishWizardResult = { - expertKey: string -} - -type UnpublishAppProps = { - experts: WizardExpertChoice[] - onFetchVersions: (expertName: string) => Promise - onComplete: (result: UnpublishWizardResult) => void - onCancel: () => void -} - -function ConfirmStep({ - expertKey, - version, - onConfirm, - onBack, -}: { - expertKey: string - version: string - onConfirm: () => void - onBack: () => void -}) { - const { exit } = useApp() - const [selectedIndex, setSelectedIndex] = useState(1) - const options = ["Yes, unpublish", "Cancel"] - useInput((input, key) => { - if (key.upArrow || key.downArrow) { - setSelectedIndex((prev) => (prev === 0 ? 1 : 0)) - } else if (key.return) { - if (selectedIndex === 0) { - onConfirm() - } else { - onBack() - } - } else if (key.escape) { - onBack() - } else if (input === "q") { - exit() - } - }) - return ( - - - ⚠ Unpublish {expertKey}? - - - Version: {version} - This action is permanent and cannot be undone. - - - {options.map((option, index) => ( - - {index === selectedIndex ? "❯ " : " "} - {option} - - ))} - - - - {KEY_HINTS.NAVIGATE} {KEY_HINTS.SELECT} {KEY_HINTS.ESC_BACK} {KEY_HINTS.QUIT} - - - - ) -} - -export function UnpublishApp({ - experts, - onFetchVersions, - onComplete, - onCancel, -}: UnpublishAppProps) { - const { exit } = useApp() - - const { step, setStep, handlers, renderBaseStep, isBaseStep } = - useExpertVersionWizard({ - experts, - onFetchVersions, - customStepFromVersion: (version) => ({ - type: "confirm", - expertKey: version.key, - version: version.version, - }), - }) - - const handleConfirm = () => { - if (step.type === "confirm") { - onComplete({ expertKey: step.expertKey }) - exit() - } - } - - const handleCustomBack = (currentStep: CustomWizardStep) => { - switch (currentStep.type) { - case "confirm": - setStep({ type: "selectExpert" }) - break - default: - onCancel() - exit() - } - } - - // Render base steps (selectExpert, loadingVersions, selectVersion, error) - const baseStepRender = renderBaseStep({ - title: "Select an Expert to unpublish:", - versionSelectorTitle: `Select a version to unpublish:`, - }) - if (baseStepRender) { - return baseStepRender - } - - // Render custom steps - if (!isBaseStep && step.type === "confirm") { - return ( - handlers.handleBack(handleCustomBack)} - /> - ) - } - - return null -} - -export type { UnpublishWizardResult } -export type { WizardExpertChoice, WizardVersionInfo } from "../types/wizard.js" diff --git a/apps/perstack/src/tui/unpublish/render.tsx b/apps/perstack/src/tui/unpublish/render.tsx deleted file mode 100644 index d60f28b2..00000000 --- a/apps/perstack/src/tui/unpublish/render.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { render } from "ink" -import type { WizardExpertChoice, WizardVersionInfo } from "../types/wizard.js" -import { UnpublishApp, type UnpublishWizardResult } from "./app.js" - -type RenderUnpublishOptions = { - experts: WizardExpertChoice[] - onFetchVersions: (expertName: string) => Promise -} -export async function renderUnpublish( - options: RenderUnpublishOptions, -): Promise { - return new Promise((resolve) => { - let result: UnpublishWizardResult | null = null - const { waitUntilExit } = render( - { - result = r - }} - onCancel={() => { - result = null - }} - />, - ) - waitUntilExit().then(() => { - resolve(result) - }) - }) -} -export type { UnpublishWizardResult } -export type { WizardExpertChoice, WizardVersionInfo } from "../types/wizard.js" diff --git a/apps/perstack/src/tui/utils/index.ts b/apps/perstack/src/tui/utils/index.ts index c37ffbec..90396fa4 100644 --- a/apps/perstack/src/tui/utils/index.ts +++ b/apps/perstack/src/tui/utils/index.ts @@ -1,3 +1,2 @@ export { createErrorHandler } from "./error-handling.js" export { EventQueue } from "./event-queue.js" -export { getStatusColor } from "./status-color.js" diff --git a/apps/perstack/src/tui/utils/status-color.ts b/apps/perstack/src/tui/utils/status-color.ts deleted file mode 100644 index f5c3ffbe..00000000 --- a/apps/perstack/src/tui/utils/status-color.ts +++ /dev/null @@ -1,13 +0,0 @@ -type StatusColor = "green" | "yellow" | "red" -export const getStatusColor = (status: string): StatusColor | undefined => { - switch (status) { - case "available": - return "green" - case "deprecated": - return "yellow" - case "disabled": - return "red" - default: - return undefined - } -} diff --git a/apps/perstack/src/unpublish.ts b/apps/perstack/src/unpublish.ts deleted file mode 100644 index 958e2e64..00000000 --- a/apps/perstack/src/unpublish.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { createApiClient } from "@perstack/api-client" -import { Command } from "commander" -import { getPerstackConfig } from "./lib/perstack-toml.js" -import { renderUnpublish, type WizardVersionInfo } from "./tui/index.js" - -export const unpublishCommand = new Command() - .command("unpublish") - .description("Remove an Expert version from the registry") - .argument("[expertKey]", "Expert key with version (e.g., my-expert@1.0.0)") - .option("--config ", "Path to perstack.toml config file") - .option("--force", "Skip confirmation prompt (required for CLI mode)") - .action(async (expertKey: string | undefined, options: { config?: string; force?: boolean }) => { - try { - const perstackConfig = await getPerstackConfig(options.config) - const client = createApiClient({ - baseUrl: perstackConfig.perstackApiBaseUrl, - apiKey: process.env.PERSTACK_API_KEY, - }) - if (!expertKey) { - const experts = perstackConfig.experts - if (!experts || Object.keys(experts).length === 0) { - console.error("No experts defined in perstack.toml") - process.exit(1) - } - const expertNames = Object.keys(experts) - const result = await renderUnpublish({ - experts: expertNames.map((name) => ({ - name, - description: experts[name].description, - })), - onFetchVersions: async (expertName: string): Promise => { - const versionsResult = await client.registry.experts.getVersions(expertName) - if (!versionsResult.ok) { - throw new Error(`Expert "${expertName}" not found in registry`) - } - const { versions } = versionsResult.data - const versionInfos: WizardVersionInfo[] = [] - for (const v of versions) { - const expertResult = await client.registry.experts.get(v.key) - if (expertResult.ok && expertResult.data.type === "registryExpert") { - versionInfos.push({ - key: v.key, - version: v.version ?? "unknown", - tags: v.tags, - status: expertResult.data.status, - }) - } else { - versionInfos.push({ - key: v.key, - version: v.version ?? "unknown", - tags: v.tags, - status: "available", - }) - } - } - return versionInfos - }, - }) - if (!result) { - console.log("Cancelled") - process.exit(0) - } - const deleteResult = await client.registry.experts.delete(result.expertKey) - if (!deleteResult.ok) { - throw new Error(deleteResult.error.message) - } - console.log(`Unpublished ${result.expertKey}`) - return - } - if (!expertKey.includes("@")) { - console.error("Expert key must include version (e.g., my-expert@1.0.0)") - process.exit(1) - } - if (!options.force) { - console.error(`This will permanently remove ${expertKey} from the registry.`) - console.error("Use --force to confirm, or run without arguments for interactive mode.") - process.exit(1) - } - const deleteResult = await client.registry.experts.delete(expertKey) - if (!deleteResult.ok) { - throw new Error(deleteResult.error.message) - } - console.log(`Unpublished ${expertKey}`) - } catch (error) { - if (error instanceof Error) { - console.error(error.message) - } else { - console.error(error) - } - process.exit(1) - } - }) diff --git a/apps/runtime/package.json b/apps/runtime/package.json index 58f785fa..ae0cd809 100644 --- a/apps/runtime/package.json +++ b/apps/runtime/package.json @@ -41,7 +41,7 @@ "@ai-sdk/openai": "^2.0.0", "@modelcontextprotocol/sdk": "^1.25.1", "@paralleldrive/cuid2": "^3.0.4", - "@perstack/api-client": "workspace:*", + "@perstack/api-client": "^0.0.51", "@perstack/base": "workspace:*", "@perstack/core": "workspace:*", "ai": "^5.0.115", diff --git a/apps/runtime/src/helpers/index.ts b/apps/runtime/src/helpers/index.ts index b3341fc2..d984c9d6 100644 --- a/apps/runtime/src/helpers/index.ts +++ b/apps/runtime/src/helpers/index.ts @@ -12,7 +12,6 @@ export { loadLockfile, } from "./lockfile.js" export { calculateContextWindowUsage, getContextWindow, getModel } from "./model.js" -export { resolveExpertToRun } from "./resolve-expert.js" export { type ResolveExpertToRunFn, type SetupExpertsResult, diff --git a/apps/runtime/src/helpers/resolve-expert.test.ts b/apps/runtime/src/helpers/resolve-expert.test.ts index 51432112..94e2903d 100644 --- a/apps/runtime/src/helpers/resolve-expert.test.ts +++ b/apps/runtime/src/helpers/resolve-expert.test.ts @@ -4,33 +4,46 @@ import { resolveExpertToRun } from "./resolve-expert.js" vi.mock("@perstack/api-client", () => ({ createApiClient: () => ({ - registry: { - experts: { - get: async () => ({ - ok: true, + experts: { + get: async (expertKey: string) => ({ + ok: true, + data: { data: { - key: "remote-expert", - name: "Remote Expert", - version: "1.0.0", - description: "A remote expert", - instruction: "Remote expert instruction", - skills: { - "@perstack/base": { - type: "mcpStdioSkill", - description: "Base skill", - command: "npx", - packageName: "@perstack/base", - requiredEnv: [], - pick: [], - omit: [], - lazyInit: false, + definition: { + name: "test-scope", + version: "1.0.0", + organizationId: "org-1", + createdAt: "2024-01-01", + updatedAt: "2024-01-01", + createdBy: "user-1", + updatedBy: "user-1", + experts: { + [expertKey]: { + name: "Remote Expert", + version: "1.0.0", + description: "A remote expert", + instruction: "Remote expert instruction", + skills: { + "@perstack/base": { + type: "mcpStdioSkill", + name: "@perstack/base", + description: "Base skill", + command: "npx", + packageName: "@perstack/base", + requiredEnv: [], + pick: [], + omit: [], + }, + }, + delegates: [], + tags: [], + }, }, }, - delegates: [], - tags: [], + yanked: false, }, - }), - }, + }, + }), }, }), })) @@ -85,6 +98,7 @@ describe("@perstack/runtime: resolveExpertToRun", () => { const experts: Record = {} const result = await resolveExpertToRun("remote-expert", experts, { perstackApiBaseUrl: "https://api.test.com", + perstackApiKey: "test-key", }) expect(result.skills["@perstack/base"].name).toBe("@perstack/base") }) @@ -93,7 +107,17 @@ describe("@perstack/runtime: resolveExpertToRun", () => { const experts: Record = {} await resolveExpertToRun("remote-expert", experts, { perstackApiBaseUrl: "https://api.test.com", + perstackApiKey: "test-key", }) expect(experts["remote-expert"]).toBeUndefined() }) + + it("throws error when API key is not provided for published expert", async () => { + const experts: Record = {} + await expect( + resolveExpertToRun("published-expert", experts, { + perstackApiBaseUrl: "https://api.test.com", + }), + ).rejects.toThrow('PERSTACK_API_KEY is required to resolve published expert "published-expert"') + }) }) diff --git a/apps/runtime/src/helpers/resolve-expert.ts b/apps/runtime/src/helpers/resolve-expert.ts index 7b979d39..9ad7faee 100644 --- a/apps/runtime/src/helpers/resolve-expert.ts +++ b/apps/runtime/src/helpers/resolve-expert.ts @@ -1,4 +1,4 @@ -import { createApiClient, type RegistryExpert } from "@perstack/api-client" +import { createApiClient } from "@perstack/api-client" import type { Expert, Skill } from "@perstack/core" export async function resolveExpertToRun( @@ -12,32 +12,132 @@ export async function resolveExpertToRun( if (experts[expertKey]) { return experts[expertKey] } + if (!clientOptions.perstackApiKey) { + throw new Error(`PERSTACK_API_KEY is required to resolve published expert "${expertKey}"`) + } const client = createApiClient({ baseUrl: clientOptions.perstackApiBaseUrl, apiKey: clientOptions.perstackApiKey, }) - const result = await client.registry.experts.get(expertKey) + const result = await client.experts.get(expertKey) if (!result.ok) { throw new Error(`Failed to resolve expert "${expertKey}": ${result.error.message}`) } - return toRuntimeExpert(result.data) + const publishedExpert = result.data.data.definition.experts[expertKey] + if (!publishedExpert) { + throw new Error(`Expert "${expertKey}" not found in API response`) + } + return toRuntimeExpert(expertKey, publishedExpert) } -function toRuntimeExpert(expert: RegistryExpert): Expert { +function toRuntimeExpert( + key: string, + expert: { + name: string + version: string + description?: string + instruction: string + skills?: Record< + string, + | { + type: "mcpStdioSkill" + name: string + description: string + rule?: string + pick?: string[] + omit?: string[] + command: "npx" | "uvx" + packageName: string + requiredEnv?: string[] + } + | { + type: "mcpSseSkill" + name: string + description: string + rule?: string + pick?: string[] + omit?: string[] + endpoint: string + } + | { + type: "interactiveSkill" + name: string + description: string + rule?: string + tools: Record + } + > + delegates?: string[] + tags?: string[] + }, +): Expert { const skills: Record = Object.fromEntries( - Object.entries(expert.skills).map(([name, skill]) => { + Object.entries(expert.skills ?? {}).map(([name, skill]) => { switch (skill.type) { case "mcpStdioSkill": - return [name, { ...skill, name }] + return [ + name, + { + type: skill.type, + name, + description: skill.description, + rule: skill.rule, + pick: skill.pick ?? [], + omit: skill.omit ?? [], + command: skill.command, + packageName: skill.packageName, + requiredEnv: skill.requiredEnv ?? [], + lazyInit: false, + }, + ] case "mcpSseSkill": - return [name, { ...skill, name }] + return [ + name, + { + type: skill.type, + name, + description: skill.description, + rule: skill.rule, + pick: skill.pick ?? [], + omit: skill.omit ?? [], + endpoint: skill.endpoint, + lazyInit: false, + }, + ] case "interactiveSkill": - return [name, { ...skill, name }] + return [ + name, + { + type: skill.type, + name, + description: skill.description, + rule: skill.rule, + tools: Object.fromEntries( + Object.entries(skill.tools).map(([toolName, tool]) => [ + toolName, + { + name: toolName, + description: tool.description, + inputSchema: JSON.parse(tool.inputJsonSchema), + }, + ]), + ), + }, + ] default: { throw new Error(`Unknown skill type: ${(skill as { type: string }).type}`) } } }), ) - return { ...expert, skills } + return { + key, + name: expert.name, + version: expert.version, + description: expert.description ?? "", + instruction: expert.instruction, + skills, + delegates: expert.delegates ?? [], + tags: expert.tags ?? [], + } } diff --git a/apps/runtime/src/helpers/setup-experts.ts b/apps/runtime/src/helpers/setup-experts.ts index 0c6daedc..77d12102 100644 --- a/apps/runtime/src/helpers/setup-experts.ts +++ b/apps/runtime/src/helpers/setup-experts.ts @@ -1,5 +1,4 @@ import type { Expert, RunSetting } from "@perstack/core" -import { resolveExpertToRun as defaultResolveExpertToRun } from "./resolve-expert.js" export type ResolveExpertToRunFn = ( expertKey: string, @@ -14,18 +13,20 @@ export type SetupExpertsResult = { export async function setupExperts( setting: RunSetting, - resolveExpertToRun: ResolveExpertToRunFn = defaultResolveExpertToRun, + resolveExpertToRun?: ResolveExpertToRunFn, ): Promise { + // Lazy-load resolve-expert to avoid importing @perstack/api-client at module load time + const resolveFn = resolveExpertToRun ?? (await import("./resolve-expert.js")).resolveExpertToRun const { expertKey } = setting const experts = { ...setting.experts } const clientOptions = { perstackApiBaseUrl: setting.perstackApiBaseUrl, perstackApiKey: setting.perstackApiKey, } - const expertToRun = await resolveExpertToRun(expertKey, experts, clientOptions) + const expertToRun = await resolveFn(expertKey, experts, clientOptions) experts[expertKey] = expertToRun for (const delegateName of expertToRun.delegates) { - const delegate = await resolveExpertToRun(delegateName, experts, clientOptions) + const delegate = await resolveFn(delegateName, experts, clientOptions) if (!delegate) { throw new Error(`Delegate ${delegateName} not found`) } diff --git a/e2e/perstack-cli/publish.test.ts b/e2e/perstack-cli/publish.test.ts deleted file mode 100644 index 63c342a5..00000000 --- a/e2e/perstack-cli/publish.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Publish Expert E2E Tests - * - * Tests CLI commands related to expert publishing: - * - publish: Publish expert to registry - * - unpublish: Remove expert from registry - * - tag: Add tags to expert version - * - status: Change expert availability status - * - * Most tests use --dry-run to avoid actual registry changes. - * These tests do NOT invoke LLM APIs. - * - * TOML: e2e/experts/cli-commands.toml - */ -import { describe, expect, it } from "vitest" -import { runCli } from "../lib/runner.js" - -const CONFIG_PATH = "./e2e/experts/cli-commands.toml" - -describe.concurrent("Publish Expert", () => { - // ───────────────────────────────────────────────────────────────────────── - // publish command tests - // ───────────────────────────────────────────────────────────────────────── - - /** Verifies --dry-run outputs JSON payload without publishing */ - it("should output JSON payload for valid expert with --dry-run", async () => { - const result = await runCli([ - "publish", - "e2e-publish-test", - "--dry-run", - "--config", - CONFIG_PATH, - ]) - expect(result.exitCode).toBe(0) - expect(result.stdout).toBeTruthy() - }) - - /** Verifies error for nonexistent expert */ - it("should fail for nonexistent expert", async () => { - const result = await runCli(["publish", "nonexistent", "--dry-run", "--config", CONFIG_PATH]) - expect(result.exitCode).toBe(1) - }) - - /** Verifies error for nonexistent config file */ - it("should fail with nonexistent config file", async () => { - const result = await runCli([ - "publish", - "e2e-publish-test", - "--dry-run", - "--config", - "nonexistent.toml", - ]) - expect(result.exitCode).toBe(1) - }) - - /** Verifies error when no config found in cwd */ - it("should fail when no config in directory", async () => { - const result = await runCli(["publish", "e2e-publish-test", "--dry-run"], { cwd: "/tmp" }) - expect(result.exitCode).toBe(1) - }) - - // ───────────────────────────────────────────────────────────────────────── - // unpublish command tests - // ───────────────────────────────────────────────────────────────────────── - - /** Verifies unpublish requires version in expert key */ - it("should fail without version", async () => { - // Note: CLI requires config file, so we provide one - const result = await runCli(["unpublish", "no-version", "--force", "--config", CONFIG_PATH]) - expect(result.exitCode).toBe(1) - expect(result.stderr).toContain("version") - }) - - /** Verifies unpublish requires --force flag */ - it("should fail without --force when version provided", async () => { - // Note: CLI requires config file, so we provide one - const result = await runCli(["unpublish", "expert@1.0.0", "--config", CONFIG_PATH]) - expect(result.exitCode).toBe(1) - expect(result.stderr).toContain("--force") - }) - - // ───────────────────────────────────────────────────────────────────────── - // tag command tests - // ───────────────────────────────────────────────────────────────────────── - - /** Verifies tag requires version in expert key */ - it("should fail without version", async () => { - const result = await runCli(["tag", "no-version", "tag1"]) - expect(result.exitCode).toBe(1) - }) - - /** Verifies tag requires at least one tag argument */ - it("should fail without tags", async () => { - const result = await runCli(["tag", "expert@1.0.0"]) - expect(result.exitCode).toBe(1) - }) - - // ───────────────────────────────────────────────────────────────────────── - // status command tests - // ───────────────────────────────────────────────────────────────────────── - - /** Verifies status requires version in expert key */ - it("should fail without version", async () => { - const result = await runCli(["status", "no-version", "available"]) - expect(result.exitCode).toBe(1) - }) - - /** Verifies status requires status value argument */ - it("should fail without status value", async () => { - const result = await runCli(["status", "expert@1.0.0"]) - expect(result.exitCode).toBe(1) - }) - - /** Verifies status rejects invalid status values */ - it("should fail with invalid status value", async () => { - const result = await runCli(["status", "expert@1.0.0", "invalid-status"]) - expect(result.exitCode).toBe(1) - }) -}) diff --git a/e2e/perstack-cli/registry.test.ts b/e2e/perstack-cli/published-expert.test.ts similarity index 74% rename from e2e/perstack-cli/registry.test.ts rename to e2e/perstack-cli/published-expert.test.ts index 56ed8916..f552d881 100644 --- a/e2e/perstack-cli/registry.test.ts +++ b/e2e/perstack-cli/published-expert.test.ts @@ -1,10 +1,10 @@ /** - * Registry E2E Tests + * Published Expert E2E Tests * - * Tests error handling for remote expert resolution: - * - Nonexistent remote experts (e.g., @user/expert) + * Tests error handling for published expert resolution: + * - Nonexistent published experts (e.g., @user/expert) * - Invalid expert key formats - * - Failed delegation to nonexistent remote experts + * - Failed delegation to nonexistent published experts * * These tests verify graceful error handling without LLM API calls * (errors occur before LLM generation starts). @@ -16,9 +16,9 @@ import { runCli } from "../lib/runner.js" const CONFIG = "./e2e/experts/error-handling.toml" -describe.concurrent("Registry", () => { +describe.concurrent("Published Expert", () => { /** Verifies error message for nonexistent @user/expert format */ - it("should fail gracefully for nonexistent remote expert", async () => { + it("should fail gracefully for nonexistent published expert", async () => { const result = await runCli([ "run", "--runtime", @@ -37,7 +37,7 @@ describe.concurrent("Registry", () => { }) /** Verifies error when expert tries to delegate to nonexistent expert */ - it("should fail gracefully when delegating to nonexistent remote expert", async () => { + it("should fail gracefully when delegating to nonexistent published expert", async () => { const result = await runCli([ "run", "--runtime", diff --git a/packages/api-client/CHANGELOG.md b/packages/api-client/CHANGELOG.md deleted file mode 100644 index c54ad0cf..00000000 --- a/packages/api-client/CHANGELOG.md +++ /dev/null @@ -1,605 +0,0 @@ -# @perstack/api-client - -## 0.0.45 - -### Patch Changes - -- [#411](https://github.com/perstack-ai/perstack/pull/411) [`b9e8b3f`](https://github.com/perstack-ai/perstack/commit/b9e8b3f5fbd631c1d3bf8c5a5ab9cdf3d3e9c2a5) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - chore: test auto-release workflow - -## 0.0.44 - -### Patch Changes - -- [#404](https://github.com/perstack-ai/perstack/pull/404) [`81c318e`](https://github.com/perstack-ai/perstack/commit/81c318e8ea318b6575abe8ef1fce7dc971dc123c) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - Fixed prohibited 'as' type assertions by replacing them with proper type guards - -## 0.0.43 - -### Patch Changes - -- Updated dependencies [[`0946b78`](https://github.com/perstack-ai/perstack/commit/0946b78c332b7c43a6f848c73b218d487b06a58d)]: - - @perstack/core@0.0.35 - -## 0.0.42 - -### Patch Changes - -- [#400](https://github.com/perstack-ai/perstack/pull/400) [`4171080`](https://github.com/perstack-ai/perstack/commit/417108055ad2bf26f06fbf49c069aa6adcbfed2e) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - Fixed `publishConfig.types` format in package.json files - - The `types` field was incorrectly specified as an object (`{ ".": "./dist/src/index.d.ts" }`) instead of being included in the `exports` field. This caused TypeScript to fail to resolve type definitions for published packages. - - Changed from: - - ```json - "publishConfig": { - "exports": { ".": "./dist/src/index.js" }, - "types": { ".": "./dist/src/index.d.ts" } - } - ``` - - To: - - ```json - "publishConfig": { - "exports": { - ".": { - "types": "./dist/src/index.d.ts", - "import": "./dist/src/index.js" - } - } - } - ``` - -- Updated dependencies [[`4171080`](https://github.com/perstack-ai/perstack/commit/417108055ad2bf26f06fbf49c069aa6adcbfed2e)]: - - @perstack/core@0.0.34 - -## 0.0.41 - -### Patch Changes - -- [#329](https://github.com/perstack-ai/perstack/pull/329) [`ddcbef4`](https://github.com/perstack-ai/perstack/commit/ddcbef4ff238e151b67f993f0a1524feab76867f) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - Fixed prohibited 'as' type assertions by replacing them with proper type guards - -- [#279](https://github.com/perstack-ai/perstack/pull/279) [`26595e0`](https://github.com/perstack-ai/perstack/commit/26595e08f411f799aeef48e3c751c02815c43f3b) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - Add parallel delegation support to TUI - - - Add `delegationComplete` checkpoint action type for tracking when all delegations return - - Add `runId` to `delegatedBy` for better delegation traceability - - Log delegate tool calls immediately at `callDelegate` event (don't wait for results) - - Add `groupLogsByRun` utility for grouping log entries by run - - Update TUI to visually group log entries by run with headers for delegated runs - - Fix `runId` generation to ensure new IDs for delegation, continuation, and delegation return - - Make `runId` internal-only (not configurable via CLI) - -- [#284](https://github.com/perstack-ai/perstack/pull/284) [`55117ae`](https://github.com/perstack-ai/perstack/commit/55117ae0d4b615ec1040885e0f01fc8e0073821d) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - Refactor event type hierarchy to fix reasoning misattribution in parallel runs (#281) - - **Breaking Changes:** - - - Renamed `CheckpointAction` to `Activity` with integrated metadata (`id`, `expertKey`, `runId`, `previousActivityId`, `delegatedBy`) - - Moved streaming events from `RuntimeEvent` to `RunEvent` (now `StreamingEvent`) - - Renamed streaming event types: - - `startReasoning` → `startStreamingReasoning` - - `completeReasoning` → `completeStreamingReasoning` - - `startRunResult` → `startStreamingRunResult` - - Added `completeStreamingRunResult` - - Removed deprecated `streamingText` event - - `@perstack/react`: Renamed `useLogStore` → `useRun`, `useRuntimeState` → `useRuntime` - - `@perstack/react`: Changed return type from `logs: LogEntry[]` to `activities: Activity[]` - - **Migration:** - - ```typescript - // Before - import { useLogStore, LogEntry, CheckpointAction } from "@perstack/react"; - const { logs } = useLogStore(); - - // After - import { useRun, Activity } from "@perstack/react"; - const { activities } = useRun(); - ``` - -- Updated dependencies [[`ba0b226`](https://github.com/perstack-ai/perstack/commit/ba0b226c3c4aded8ab4612719d0816363a46092b), [`15ab983`](https://github.com/perstack-ai/perstack/commit/15ab98364f08bf63f3019597b9ee8e0db2dc250f), [`0515dd9`](https://github.com/perstack-ai/perstack/commit/0515dd9701931791ca53b71cccbc82e105d60874), [`734f797`](https://github.com/perstack-ai/perstack/commit/734f797ccb76e3fdf17cac88e735ded24036e094), [`26595e0`](https://github.com/perstack-ai/perstack/commit/26595e08f411f799aeef48e3c751c02815c43f3b), [`b8f2dec`](https://github.com/perstack-ai/perstack/commit/b8f2dec5516bc07a89d64abadbb069956557eb40), [`55117ae`](https://github.com/perstack-ai/perstack/commit/55117ae0d4b615ec1040885e0f01fc8e0073821d), [`3f0821f`](https://github.com/perstack-ai/perstack/commit/3f0821f885cfbf43b2ca21ce98d947f30c6bff97)]: - - @perstack/core@0.0.33 - -## 0.0.40 - -### Patch Changes - -- [`86c709e`](https://github.com/perstack-ai/perstack/commit/86c709e021443f911573f54ceb79d632a3124d46) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - Release test - -- Updated dependencies [[`86c709e`](https://github.com/perstack-ai/perstack/commit/86c709e021443f911573f54ceb79d632a3124d46)]: - - @perstack/core@0.0.32 - -## 0.0.39 - -### Patch Changes - -- [`5f13501`](https://github.com/perstack-ai/perstack/commit/5f13501d1101be6fca5ac97f3e4594158c34ab04) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - Internal improvements and maintenance updates - -- Updated dependencies [[`5f13501`](https://github.com/perstack-ai/perstack/commit/5f13501d1101be6fca5ac97f3e4594158c34ab04)]: - - @perstack/core@0.0.31 - -## 0.0.38 - -### Patch Changes - -- [`0e6eceb`](https://github.com/perstack-ai/perstack/commit/0e6eceb8e021d0b631a7fe34de3036fcaf9e6c9d) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - Internal improvements and maintenance updates - -- Updated dependencies [[`0e6eceb`](https://github.com/perstack-ai/perstack/commit/0e6eceb8e021d0b631a7fe34de3036fcaf9e6c9d)]: - - @perstack/core@0.0.30 - -## 0.0.37 - -### Patch Changes - -- Internal improvements and maintenance updates - -- Updated dependencies []: - - @perstack/core@0.0.29 - -## 0.0.36 - -### Patch Changes - -- [#256](https://github.com/perstack-ai/perstack/pull/256) [`dacb0eb`](https://github.com/perstack-ai/perstack/commit/dacb0eb3718a2cce918a6ab455ffb9ef6cce99c9) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - Rewrote @perstack/api-client with modern patterns - - - Replaced class-based `ApiV1Client` with functional `createApiClient()` - - Introduced Result pattern for error handling (no exceptions for HTTP errors) - - Added namespace-style API access (e.g., `client.registry.experts.get()`) - - Simplified test utilities by removing unused OpenAPI spec-based mock generation - - Migrated all consumers to use the new API - -- [#265](https://github.com/perstack-ai/perstack/pull/265) [`8555f5b`](https://github.com/perstack-ai/perstack/commit/8555f5b842e6bb26f667e52b5ce383e6a6c7317e) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - Internal improvements and maintenance updates - -- Updated dependencies [[`8555f5b`](https://github.com/perstack-ai/perstack/commit/8555f5b842e6bb26f667e52b5ce383e6a6c7317e)]: - - @perstack/core@0.0.28 - -## 0.0.35 - -### Patch Changes - -- [#248](https://github.com/perstack-ai/perstack/pull/248) [`d6b7d4d`](https://github.com/perstack-ai/perstack/commit/d6b7d4d34fa9f92c57d324884e4fa6603ec577a1) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - Add `perstack log` command for viewing execution history and events. - - This command enables developers and AI agents to inspect job/run history for debugging purposes. Features include: - - - View events by job, run, or checkpoint - - Filter events by type, step number, or custom expressions - - Preset filters for errors, tools, and delegations - - Human-readable terminal output with colors - - JSON output for machine parsing - - Summary view for quick diagnosis - -- [#235](https://github.com/perstack-ai/perstack/pull/235) [`90b86c0`](https://github.com/perstack-ai/perstack/commit/90b86c0e503dac95a3d6bc1a29a6f5d8d35dd666) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - feat: bundle @perstack/base into runtime with InMemoryTransport - - Eliminates ~500ms startup latency for the base skill by using in-process MCP communication via InMemoryTransport. The bundled base skill now runs in the same process as the runtime, achieving near-zero initialization latency (<50ms). - - Key changes: - - - Added `createBaseServer()` export from @perstack/base for in-process server creation - - Added `InMemoryTransport` support to transport factory - - Added `InMemoryBaseSkillManager` for bundled base skill execution - - Runtime now uses bundled base by default (no version specified) - - Explicit version pinning (e.g., `@perstack/base@0.0.34`) falls back to npx + StdioTransport - - This is the foundation for #197 (perstack install) which will enable instant Expert startup. - -- [#202](https://github.com/perstack-ai/perstack/pull/202) [`0653050`](https://github.com/perstack-ai/perstack/commit/065305088dce72c2cf68873a1485c98183174c78) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - feat: add granular timing metrics to MCP skill initialization - - The `skillConnected` runtime event now includes detailed timing breakdown: - - - `spawnDurationMs` - Time to create transport and spawn process - - `handshakeDurationMs` - Time for MCP protocol handshake (connect) - - `toolDiscoveryDurationMs` - Time for listTools() call - - These metrics help identify performance bottlenecks in MCP skill startup. - - **Breaking behavior change**: The semantics of existing fields have changed: - - - `connectDurationMs` now equals `spawnDurationMs + handshakeDurationMs` (previously measured only `connect()` call) - - `totalDurationMs` now includes `toolDiscoveryDurationMs` (previously captured before `listTools()`) - - Example event output: - - ```json - { - "type": "skillConnected", - "skillName": "@perstack/base", - "spawnDurationMs": 150, - "handshakeDurationMs": 8500, - "toolDiscoveryDurationMs": 1100, - "connectDurationMs": 8650, - "totalDurationMs": 9750 - } - ``` - - Relates to #201 - -- [#240](https://github.com/perstack-ai/perstack/pull/240) [`26e1109`](https://github.com/perstack-ai/perstack/commit/26e11097a65c1b2cc9aa74f48b53026df3eaa4b0) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - feat: introduce `perstack install` command and `perstack.lock` for faster startup - - This feature enables instant LLM inference by pre-collecting tool definitions: - - - Added `perstack install` command that generates `perstack.lock` file - - Lockfile contains all expert definitions and tool schemas from MCP skills - - Runtime uses lockfile to skip MCP initialization and start inference immediately - - Skills are lazily initialized only when their tools are actually called - - Benefits: - - - Near-zero startup latency (from 500ms-6s per skill to <50ms total) - - Reproducible builds with locked tool definitions - - Faster production deployments - -- [#225](https://github.com/perstack-ai/perstack/pull/225) [`beb1edc`](https://github.com/perstack-ai/perstack/commit/beb1edc7222231ac67cf0653331e4644b162ca8b) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - Add pluggable LLM provider architecture with ProviderAdapter pattern - - - Introduce `ProviderAdapter` interface and `BaseProviderAdapter` abstract class in `@perstack/provider-core` - - Add provider-specific adapters for Anthropic, OpenAI, Google, Ollama, Azure OpenAI, Bedrock, Vertex, and DeepSeek - - Add `LLMExecutor` layer to encapsulate LLM calls with provider-specific error handling and retry logic - - Add `ProviderAdapterFactory` with dynamic import support for future npm package installation pattern - - Extend Expert and PerstackConfigExpert schemas to support `providerTools`, `providerSkills`, and `providerToolOptions` - - Add `createTestContext` helper for improved test ergonomics - -- [#236](https://github.com/perstack-ai/perstack/pull/236) [`d88f116`](https://github.com/perstack-ai/perstack/commit/d88f116edbbb8ffa4240066f0bacb70f442e1123) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - Add native LLM reasoning support (Extended Thinking/Reasoning) - - - Add `reasoningBudget` option to enable test-time scaling - - Support Anthropic Extended Thinking with thinking block preservation - - Support OpenAI Reasoning Effort with effort level mapping - - Support Google Flash Thinking with thinkingBudget configuration - - Add `ThinkingPart` message type for conversation history - - Add `thinking` field to `completeRun` event for observability - -- [#247](https://github.com/perstack-ai/perstack/pull/247) [`9da758b`](https://github.com/perstack-ai/perstack/commit/9da758b3b59047a7086d5748dbaa586bbd9dbca1) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - Add S3 and R2 storage backends with unified Storage interface - - - Add `Storage` interface and `EventMeta` type to `@perstack/core` - - Create `@perstack/s3-compatible-storage` package with shared S3 logic - - Create `@perstack/s3-storage` package for AWS S3 storage - - Create `@perstack/r2-storage` package for Cloudflare R2 storage - - Add `FileSystemStorage` class to `@perstack/filesystem-storage` implementing Storage interface - - Maintain backward compatibility with existing function exports - -- [#241](https://github.com/perstack-ai/perstack/pull/241) [`0831c63`](https://github.com/perstack-ai/perstack/commit/0831c63c1484dd9b0a6c6ce95504d46c05086aa4) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - Add streaming output events for real-time LLM output display - - - New event types: `startReasoning`, `streamReasoning`, `startRunResult`, `streamRunResult` - - Fire-and-forget streaming events emitted during LLM generation - - TUI displays streaming reasoning and run results in real-time - - Reasoning phase properly completes before result phase - - Added retry count tracking with configurable limit via `maxRetries` - - TUI now displays retry events with reason - -### Patch Changes - -- [#172](https://github.com/perstack-ai/perstack/pull/172) [`7792a8d`](https://github.com/perstack-ai/perstack/commit/7792a8df1aa988ae04c40f4ee737e5086b9cacca) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - Change default runtime from `perstack` to `docker` for security-by-default posture. - - **Breaking Changes:** - - - Default runtime is now `docker` instead of `perstack` - - The `perstack` runtime has been renamed to `local` - - **Migration:** - - If you have `runtime = "perstack"` in your `perstack.toml`, update it to `runtime = "local"`. - - The `docker` runtime provides container isolation and network restrictions by default. Use `--runtime local` only for trusted environments where Docker is not available. - -- [#171](https://github.com/perstack-ai/perstack/pull/171) [`5b07fd7`](https://github.com/perstack-ai/perstack/commit/5b07fd7ba21fae211ab38e808881c9bdc80de718) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - feat: display Docker progress and proxy status during perstack start - - When running `perstack start` with `--runtime docker --verbose`, users can now see: - - - Docker image build progress (pulling layers, installing deps) - - Container startup and health check status - - Real-time proxy allow/block events for network requests - - This provides better visibility into Docker container lifecycle and helps debug network issues when using the Squid proxy for domain-based allowlist. - - New runtime event types: - - - `dockerBuildProgress` - Image build progress (pulling, building, complete, error) - - `dockerContainerStatus` - Container status (starting, running, healthy, stopped) - - `proxyAccess` - Proxy allow/block events with domain and port information - - Example TUI output: - - ``` - Docker Build [runtime] Building Installing dependencies... - Docker [proxy] Healthy Proxy container ready - Proxy ✓ api.anthropic.com:443 - Proxy ✗ blocked.com:443 Domain not in allowlist - ``` - - Closes #165, #167 - -- [#151](https://github.com/perstack-ai/perstack/pull/151) [`51159b6`](https://github.com/perstack-ai/perstack/commit/51159b6e9fabed47134cbb94f1145e950928bca0) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - Multi-runtime support and Docker enhancements - - Features: - - - Add Docker runtime adapter with container isolation - - Add multi-runtime support (cursor, claude-code, gemini) - - Add create-expert interactive wizard - - Add @perstack/runner for centralized adapter dispatch - - Add @perstack/filesystem-storage for modular storage layer - - Add @perstack/e2e-mcp-server for security testing - - Add --workspace option for Docker runtime volume mounting - - Support GitHub URL for --config option - - Security: - - - Comprehensive Docker sandbox hardening - - Network isolation with HTTPS-only proxy - - Filesystem isolation with path validation - - Environment variable filtering - - SSRF protection (metadata endpoints, private IPs) - - Improvements: - - - Switch Docker WORKDIR to /workspace for natural relative path resolution - - Reorganize E2E tests with security audit trails - - Add runtime field to TUI and Registry API - - Add verbose output for Docker build progress with --verbose flag - -- Updated dependencies [[`d6b7d4d`](https://github.com/perstack-ai/perstack/commit/d6b7d4d34fa9f92c57d324884e4fa6603ec577a1), [`90b86c0`](https://github.com/perstack-ai/perstack/commit/90b86c0e503dac95a3d6bc1a29a6f5d8d35dd666), [`7792a8d`](https://github.com/perstack-ai/perstack/commit/7792a8df1aa988ae04c40f4ee737e5086b9cacca), [`edec35e`](https://github.com/perstack-ai/perstack/commit/edec35e728c89ef98873cee9594ecc3a853d3999), [`5b07fd7`](https://github.com/perstack-ai/perstack/commit/5b07fd7ba21fae211ab38e808881c9bdc80de718), [`80a58ed`](https://github.com/perstack-ai/perstack/commit/80a58edf047bc0ef883745afd52173dfc4162669), [`f5dc244`](https://github.com/perstack-ai/perstack/commit/f5dc244339238f080661c6e73f652cd737d3c218), [`a01ee65`](https://github.com/perstack-ai/perstack/commit/a01ee65fa1932726928744bb625c3196b499a20b), [`0653050`](https://github.com/perstack-ai/perstack/commit/065305088dce72c2cf68873a1485c98183174c78), [`26e1109`](https://github.com/perstack-ai/perstack/commit/26e11097a65c1b2cc9aa74f48b53026df3eaa4b0), [`beb1edc`](https://github.com/perstack-ai/perstack/commit/beb1edc7222231ac67cf0653331e4644b162ca8b), [`58ddc86`](https://github.com/perstack-ai/perstack/commit/58ddc8690ff6a399a270c487e8035065efa03fb5), [`d88f116`](https://github.com/perstack-ai/perstack/commit/d88f116edbbb8ffa4240066f0bacb70f442e1123), [`9da758b`](https://github.com/perstack-ai/perstack/commit/9da758b3b59047a7086d5748dbaa586bbd9dbca1), [`90f7de0`](https://github.com/perstack-ai/perstack/commit/90f7de06a92f98df771a71cc663da607f11d8194), [`0831c63`](https://github.com/perstack-ai/perstack/commit/0831c63c1484dd9b0a6c6ce95504d46c05086aa4), [`51159b6`](https://github.com/perstack-ai/perstack/commit/51159b6e9fabed47134cbb94f1145e950928bca0)]: - - @perstack/core@0.1.0 - -## 0.0.33 - -### Patch Changes - -- [#78](https://github.com/perstack-ai/perstack/pull/78) [`654ea63`](https://github.com/perstack-ai/perstack/commit/654ea635245f77baa43020dcab75efe31ab42cf4) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - Add Job concept as parent container for Runs - - - Add Job schema and jobId to Checkpoint, RunSetting, and Event types - - Update storage structure to perstack/jobs/{jobId}/runs/{runId}/ - - Update CLI options: --job-id, --continue-job (replacing --continue-run) - -- Updated dependencies [[`654ea63`](https://github.com/perstack-ai/perstack/commit/654ea635245f77baa43020dcab75efe31ab42cf4)]: - - @perstack/core@0.0.22 - -## 0.0.32 - -### Patch Changes - -- [#62](https://github.com/perstack-ai/perstack/pull/62) [`3b64f88`](https://github.com/perstack-ai/perstack/commit/3b64f886b2e6f030d0e75d0baf4b51fb4d3747b8) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - Add parallel tool call support and mixed tool call handling - - Features: - - - Process all tool calls from a single LLM response instead of only the first one - - MCP tools execute in parallel using `Promise.all` - - Support mixed tool calls (MCP + Delegate + Interactive in same response) - - Process tools in priority order: MCP → Delegate → Interactive - - Preserve partial results across checkpoint boundaries - - Schema Changes: - - - `Step.toolCall` → `Step.toolCalls` (array) - - `Step.toolResult` → `Step.toolResults` (array) - - Add `Step.pendingToolCalls` for tracking unprocessed tool calls - - Add `Checkpoint.pendingToolCalls` and `Checkpoint.partialToolResults` for resume - - Event Changes: - - - `callTool` → `callTools` - - `resolveToolResult` → `resolveToolResults` - - Add `resumeToolCalls` and `finishAllToolCalls` events - -- Updated dependencies [[`3b64f88`](https://github.com/perstack-ai/perstack/commit/3b64f886b2e6f030d0e75d0baf4b51fb4d3747b8)]: - - @perstack/core@0.0.21 - -## 0.0.31 - -### Patch Changes - -- Updated dependencies [[`5497b47`](https://github.com/perstack-ai/perstack/commit/5497b478476ef95688a9cb28cfaf20473e6ae3ce)]: - - @perstack/core@0.0.20 - -## 0.0.30 - -### Patch Changes - -- [#45](https://github.com/perstack-ai/perstack/pull/45) [`af20acb`](https://github.com/perstack-ai/perstack/commit/af20acb717b74df1d59164858e3848d6da48a21a) Thanks [@FL4TLiN3](https://github.com/FL4TLiN3)! - Add event detail view in history browser - - Users can now select an event in the events list to view its details including type, step number, timestamp, and IDs. - -- Updated dependencies [[`af20acb`](https://github.com/perstack-ai/perstack/commit/af20acb717b74df1d59164858e3848d6da48a21a)]: - - @perstack/core@0.0.19 - -## 0.0.29 - -### Patch Changes - -- Updated dependencies [[`eb2f4cc`](https://github.com/perstack-ai/perstack/commit/eb2f4cc1900bb7ae02bf21c375bdade891d9823e)]: - - @perstack/core@0.0.18 - -## 0.0.28 - -### Patch Changes - -- Updated dependencies - - @perstack/core@0.0.17 - -## 0.0.27 - -### Patch Changes - -- Add health check tool, Zod error formatting, and refactor SkillManager - - Features: - - - Add healthCheck tool to @perstack/base for runtime health monitoring - - Add friendly Zod error formatting utility to @perstack/core - - Export BaseEvent interface from @perstack/core - - Improvements: - - - Refactor SkillManager into separate classes (McpSkillManager, InteractiveSkillManager, DelegateSkillManager) - - Use discriminatedUnion for provider settings in perstack.toml schema - - Add JSDoc documentation to all core schema types - - Add Skill Management documentation - -- Updated dependencies - - @perstack/core@0.0.16 - -## 0.0.26 - -### Patch Changes - -- init -- Updated dependencies - - @perstack/core@0.0.15 - -## 0.0.25 - -### Patch Changes - -- init -- Updated dependencies - - @perstack/core@0.0.14 - -## 0.0.24 - -### Patch Changes - -- init -- Updated dependencies - - @perstack/core@0.0.13 - -## 0.0.23 - -### Patch Changes - -- init -- Updated dependencies - - @perstack/core@0.0.12 - -## 0.0.22 - -### Patch Changes - -- init -- Updated dependencies - - @perstack/core@0.0.11 - -## 0.0.21 - -### Patch Changes - -- init -- Updated dependencies - - @perstack/core@0.0.10 - -## 0.0.20 - -### Patch Changes - -- init -- Updated dependencies - - @perstack/core@0.0.9 - -## 0.0.19 - -### Patch Changes - -- init - -## 0.0.18 - -### Patch Changes - -- init - -## 0.0.17 - -### Patch Changes - -- init -- init -- init - -## 0.0.15 - -### Patch Changes - -- Updated dependencies - - @perstack/core@0.0.8 - -## 0.0.14 - -### Patch Changes - -- Updated dependencies - - @perstack/core@0.0.7 - -## 0.0.13 - -### Patch Changes - -- init -- Updated dependencies - - @perstack/core@0.0.6 - -## 0.0.12 - -### Patch Changes - -- init - -## 0.0.11 - -### Patch Changes - -- add interactive tool calling - -## 0.0.10 - -### Patch Changes - -- Add interactive tool call action type - -## 0.0.9 - -### Patch Changes - -- Init - -## 0.0.8 - -### Patch Changes - -- Init - -## 0.0.7 - -### Patch Changes - -- Init - -## 0.0.6 - -### Patch Changes - -- Init - -## 0.0.5 - -### Patch Changes - -- Init -- Updated dependencies - - @perstack/core@0.0.5 - -## 0.0.4 - -### Patch Changes - -- Init -- Updated dependencies - - @perstack/core@0.0.4 - -## 0.0.3 - -### Patch Changes - -- Test: Setting up changeset -- Updated dependencies - - @perstack/core@0.0.3 - -## 0.0.2 - -### Patch Changes - -- Test -- Updated dependencies - - @perstack/core@0.0.2 diff --git a/packages/api-client/README.md b/packages/api-client/README.md deleted file mode 100644 index 4cf3fc9f..00000000 --- a/packages/api-client/README.md +++ /dev/null @@ -1,126 +0,0 @@ -# @perstack/api-client - -The official TypeScript/JavaScript API client for Perstack. - -For API reference, see [Registry API](https://github.com/perstack-ai/perstack/blob/main/docs/references/registry-api.md). - -## Installation - -```bash -npm install @perstack/api-client -# or -pnpm add @perstack/api-client -# or -yarn add @perstack/api-client -``` - -## Usage - -### Initialization - -```typescript -import { ApiV1Client } from "@perstack/api-client"; - -const client = new ApiV1Client({ - baseUrl: "https://api.perstack.ai", // Optional, defaults to https://api.perstack.ai - apiKey: "YOUR_API_KEY", // Required for authenticated requests -}); -``` - -### Registry - -Interact with the Expert Registry. - -```typescript -// Get all experts in the registry -const experts = await client.registry.experts.getMany({}); - -// Get a specific expert -const expert = await client.registry.experts.get({ - owner: "perstack", - slug: "software-engineer", -}); - -// Get expert versions -const versions = await client.registry.experts.getVersions({ - owner: "perstack", - slug: "software-engineer", -}); -``` - -### Studio - -Interact with the Studio (Experts, Jobs, Workspace). - -#### Experts - -```typescript -// Create a studio expert -const newExpert = await client.studio.experts.create({ - slug: "my-custom-expert", - description: "A custom expert for my needs", -}); - -// Get studio experts -const myExperts = await client.studio.experts.getMany({}); -``` - -#### Expert Jobs - -Manage expert execution jobs. - -```typescript -// Start a job -const job = await client.studio.expertJobs.start({ - expertId: "expert-id", - input: { - // ... input data - }, -}); - -// Get job status -const jobStatus = await client.studio.expertJobs.get({ - id: job.id, -}); - -// Continue a job (if waiting for input) -await client.studio.expertJobs.continue({ - id: job.id, - input: { - // ... user input - }, -}); -``` - -#### Workspace - -Manage workspace resources (Items, Variables, Secrets). - -```typescript -// Get workspace details -const workspace = await client.studio.workspace.get(); - -// Create a workspace item (file) -await client.studio.workspace.items.create({ - path: "/path/to/file.txt", - content: "Hello, World!", -}); - -// Create a secret -await client.studio.workspace.secrets.create({ - key: "OPENAI_API_KEY", - value: "sk-...", -}); -``` - -## Error Handling - -The client throws errors for failed requests. - -```typescript -try { - await client.registry.experts.get({ owner: "invalid", slug: "expert" }); -} catch (error) { - console.error("Failed to fetch expert:", error); -} -``` diff --git a/packages/api-client/package.json b/packages/api-client/package.json deleted file mode 100644 index 1718f080..00000000 --- a/packages/api-client/package.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "@perstack/api-client", - "version": "0.0.45", - "description": "Perstack API Client", - "author": "Wintermute Technologies, Inc.", - "license": "Apache-2.0", - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "publishConfig": { - "access": "public", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - } - }, - "files": [ - "dist" - ], - "scripts": { - "clean": "rm -rf dist", - "build": "pnpm run clean && tsup --config ./tsup.config.ts", - "typecheck": "tsc --noEmit", - "test": "vitest run" - }, - "dependencies": { - "@perstack/core": "workspace:*", - "zod": "^4.2.1" - }, - "devDependencies": { - "@tsconfig/node22": "^22.0.5", - "@types/node": "^25.0.3", - "msw": "^2.12.4", - "tsup": "^8.5.1", - "typescript": "^5.9.3", - "vitest": "^4.0.16" - } -} diff --git a/packages/api-client/src/client.test.ts b/packages/api-client/src/client.test.ts deleted file mode 100644 index 32998acd..00000000 --- a/packages/api-client/src/client.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { setupServer } from "msw/node" -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest" -import { createApiClient } from "./client.js" -import { overrideErrorHandler, overrideHandler } from "./test-utils/mock-server.js" - -const server = setupServer() - -beforeAll(() => server.listen({ onUnhandledRequest: "error" })) -afterEach(() => server.resetHandlers()) -afterAll(() => server.close()) - -describe("createApiClient", () => { - it("creates a client with all namespaces", () => { - const client = createApiClient() - expect(client.registry).toBeDefined() - expect(client.registry.experts).toBeDefined() - expect(client.studio).toBeDefined() - expect(client.studio.experts).toBeDefined() - expect(client.studio.expertJobs).toBeDefined() - expect(client.studio.workspace).toBeDefined() - }) - - it("accepts custom baseUrl and apiKey", () => { - const client = createApiClient({ - baseUrl: "https://custom.api.com", - apiKey: "test-key", - }) - expect(client).toBeDefined() - }) - - it("accepts timeout configuration", () => { - const client = createApiClient({ - timeout: 5000, - }) - expect(client).toBeDefined() - }) -}) - -describe("registry.experts", () => { - describe("get", () => { - it("returns an expert on success", async () => { - const mockExpert = { - type: "registryExpert" as const, - id: "test-id", - key: "my-expert@1.0.0", - name: "my-expert", - minRuntimeVersion: "v1.0" as const, - description: "Test expert", - owner: { - name: "test-org", - organizationId: "org123456789012345678901", - createdAt: "2024-01-01T00:00:00Z", - }, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - version: "1.0.0", - status: "available" as const, - instruction: "Test instruction", - skills: {}, - delegates: [], - tags: ["latest"], - } - - server.use( - overrideHandler("get", "/api/registry/v1/experts/my-expert%401.0.0", { - data: { expert: mockExpert }, - }), - ) - - const client = createApiClient() - const result = await client.registry.experts.get("my-expert@1.0.0") - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.name).toBe("my-expert") - expect(result.data.version).toBe("1.0.0") - } - }) - - it("returns error on 404", async () => { - server.use( - overrideErrorHandler("get", "/api/registry/v1/experts/not-found%401.0.0", { - code: 404, - error: "Not Found", - reason: "Expert not found", - }), - ) - - const client = createApiClient() - const result = await client.registry.experts.get("not-found@1.0.0") - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.code).toBe(404) - expect(result.error.message).toBe("Not Found") - } - }) - }) - - describe("list", () => { - it("returns paginated experts", async () => { - const mockExperts = [ - { - type: "registryExpert" as const, - id: "test-id", - key: "expert-1@1.0.0", - name: "expert-1", - minRuntimeVersion: "v1.0" as const, - description: "Test expert 1", - owner: { - organizationId: "org123456789012345678901", - createdAt: "2024-01-01T00:00:00Z", - }, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - version: "1.0.0", - status: "available" as const, - instruction: "Test", - skills: {}, - delegates: [], - tags: [], - }, - ] - - server.use( - overrideHandler("get", "/api/registry/v1/experts/", { - data: { experts: mockExperts }, - meta: { total: 1, take: 100, skip: 0 }, - }), - ) - - const client = createApiClient() - const result = await client.registry.experts.list() - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.data).toHaveLength(1) - expect(result.data.meta.total).toBe(1) - } - }) - }) -}) - -describe("studio.experts", () => { - describe("get", () => { - it("returns a studio expert", async () => { - const mockExpert = { - type: "studioExpert" as const, - id: "test-id", - key: "my-studio-expert", - name: "my-studio-expert", - minRuntimeVersion: "v1.0" as const, - description: "Test studio expert", - owner: { - organizationId: "org123456789012345678901", - createdAt: "2024-01-01T00:00:00Z", - }, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - instruction: "Test instruction", - skills: {}, - delegates: [], - application: { - id: "app123", - name: "test-app", - }, - } - - server.use( - overrideHandler("get", "/api/studio/v1/experts/my-studio-expert", { - data: { expert: mockExpert }, - }), - ) - - const client = createApiClient() - const result = await client.studio.experts.get("my-studio-expert") - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.type).toBe("studioExpert") - expect(result.data.name).toBe("my-studio-expert") - } - }) - }) -}) - -describe("studio.expertJobs", () => { - describe("start", () => { - it("starts a new expert job", async () => { - const mockJob = { - id: "job-123", - expertKey: "my-expert@1.0.0", - status: "queued" as const, - query: "Test query", - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - } - - server.use( - overrideHandler("post", "/api/studio/v1/expert_jobs/", { - data: { expertJob: mockJob }, - }), - ) - - const client = createApiClient() - const result = await client.studio.expertJobs.start({ - expertKey: "my-expert@1.0.0", - query: "Test query", - }) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.id).toBe("job-123") - expect(result.data.status).toBe("queued") - } - }) - }) -}) - -describe("studio.workspace", () => { - describe("get", () => { - it("returns workspace info", async () => { - const mockWorkspace = { - id: "ws-123", - applicationId: "app-123", - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - } - - server.use( - overrideHandler("get", "/api/studio/v1/workspace/", { - data: { workspace: mockWorkspace }, - }), - ) - - const client = createApiClient() - const result = await client.studio.workspace.get() - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.id).toBe("ws-123") - } - }) - }) -}) diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts deleted file mode 100644 index 6bc0baa9..00000000 --- a/packages/api-client/src/client.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { createFetcher } from "./fetcher.js" -import { createRegistryExpertsApi, type RegistryExpertsApi } from "./registry/experts.js" -import { createStudioExpertJobsApi, type StudioExpertJobsApi } from "./studio/expert-jobs.js" -import { createStudioExpertsApi, type StudioExpertsApi } from "./studio/experts.js" -import { createStudioWorkspaceApi, type StudioWorkspaceApi } from "./studio/workspace.js" -import type { ApiClientConfig } from "./types.js" - -export interface ApiClient { - registry: { - experts: RegistryExpertsApi - } - studio: { - experts: StudioExpertsApi - expertJobs: StudioExpertJobsApi - workspace: StudioWorkspaceApi - } -} - -export function createApiClient(config?: ApiClientConfig): ApiClient { - const fetcher = createFetcher(config) - - return { - registry: { - experts: createRegistryExpertsApi(fetcher), - }, - studio: { - experts: createStudioExpertsApi(fetcher), - expertJobs: createStudioExpertJobsApi(fetcher), - workspace: createStudioWorkspaceApi(fetcher), - }, - } -} diff --git a/packages/api-client/src/fetcher.test.ts b/packages/api-client/src/fetcher.test.ts deleted file mode 100644 index b20d09e4..00000000 --- a/packages/api-client/src/fetcher.test.ts +++ /dev/null @@ -1,439 +0,0 @@ -import { HttpResponse, http } from "msw" -import { setupServer } from "msw/node" -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest" -import { z } from "zod" -import { createFetcher } from "./fetcher.js" - -const server = setupServer() - -beforeAll(() => server.listen({ onUnhandledRequest: "error" })) -afterEach(() => server.resetHandlers()) -afterAll(() => server.close()) - -const BASE_URL = "https://api.perstack.ai" - -describe("createFetcher", () => { - it("creates a fetcher with default config", () => { - const fetcher = createFetcher() - expect(fetcher).toBeDefined() - expect(fetcher.get).toBeInstanceOf(Function) - expect(fetcher.post).toBeInstanceOf(Function) - expect(fetcher.delete).toBeInstanceOf(Function) - expect(fetcher.getBlob).toBeInstanceOf(Function) - }) - - it("uses custom baseUrl", async () => { - const customUrl = "https://custom.api.com" - server.use( - http.get(`${customUrl}/test`, () => { - return HttpResponse.json({ value: "custom" }) - }), - ) - - const fetcher = createFetcher({ baseUrl: customUrl }) - const schema = z.object({ value: z.string() }) - const result = await fetcher.get("/test", schema) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.value).toBe("custom") - } - }) - - it("sets authorization header when apiKey is provided", async () => { - let capturedAuth: string | null = null - server.use( - http.get(`${BASE_URL}/test`, ({ request }) => { - capturedAuth = request.headers.get("Authorization") - return HttpResponse.json({ ok: true }) - }), - ) - - const fetcher = createFetcher({ apiKey: "test-api-key" }) - const schema = z.object({ ok: z.boolean() }) - await fetcher.get("/test", schema) - - expect(capturedAuth).toBe("Bearer test-api-key") - }) - - it("does not set authorization header when apiKey is not provided", async () => { - let capturedAuth: string | null = null - server.use( - http.get(`${BASE_URL}/test`, ({ request }) => { - capturedAuth = request.headers.get("Authorization") - return HttpResponse.json({ ok: true }) - }), - ) - - const fetcher = createFetcher() - const schema = z.object({ ok: z.boolean() }) - await fetcher.get("/test", schema) - - expect(capturedAuth).toBeNull() - }) -}) - -describe("fetcher.get", () => { - it("returns parsed data on success", async () => { - server.use( - http.get(`${BASE_URL}/api/test`, () => { - return HttpResponse.json({ id: 123, name: "test" }) - }), - ) - - const fetcher = createFetcher() - const schema = z.object({ id: z.number(), name: z.string() }) - const result = await fetcher.get("/api/test", schema) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toEqual({ id: 123, name: "test" }) - } - }) - - it("returns error on HTTP error with JSON body", async () => { - server.use( - http.get(`${BASE_URL}/api/test`, () => { - return HttpResponse.json( - { error: "Not Found", reason: "Resource not found" }, - { status: 404 }, - ) - }), - ) - - const fetcher = createFetcher() - const schema = z.object({ id: z.number() }) - const result = await fetcher.get("/api/test", schema) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.code).toBe(404) - expect(result.error.message).toBe("Not Found") - expect(result.error.reason).toBe("Resource not found") - } - }) - - it("returns error on HTTP error without JSON body", async () => { - server.use( - http.get(`${BASE_URL}/api/test`, () => { - return new HttpResponse("Internal Server Error", { status: 500 }) - }), - ) - - const fetcher = createFetcher() - const schema = z.object({ id: z.number() }) - const result = await fetcher.get("/api/test", schema) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.code).toBe(500) - expect(result.error.message).toBe("Internal Server Error") - } - }) - - it("returns error with reason when body has reason but no error field", async () => { - server.use( - http.get(`${BASE_URL}/api/test`, () => { - return HttpResponse.json({ reason: "Detailed reason without error" }, { status: 400 }) - }), - ) - - const fetcher = createFetcher() - const schema = z.object({ id: z.number() }) - const result = await fetcher.get("/api/test", schema) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.code).toBe(400) - expect(result.error.message).toBe("Bad Request") - expect(result.error.reason).toBe("Detailed reason without error") - } - }) - - it("returns error with statusText when body has non-string error field", async () => { - server.use( - http.get(`${BASE_URL}/api/test`, () => { - return HttpResponse.json({ error: 123, reason: "Some reason" }, { status: 422 }) - }), - ) - - const fetcher = createFetcher() - const schema = z.object({ id: z.number() }) - const result = await fetcher.get("/api/test", schema) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.code).toBe(422) - expect(result.error.message).toBe("Unprocessable Entity") - expect(result.error.reason).toBe("Some reason") - } - }) - - it("returns validation error on schema mismatch", async () => { - server.use( - http.get(`${BASE_URL}/api/test`, () => { - return HttpResponse.json({ unexpectedField: true }) - }), - ) - - const fetcher = createFetcher() - const schema = z.object({ id: z.number() }) - const result = await fetcher.get("/api/test", schema) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.code).toBe(0) - expect(result.error.message).toBe("Response validation failed") - expect(result.error.reason).toBeDefined() - } - }) - - it("returns abort error on request abort", async () => { - server.use( - http.get(`${BASE_URL}/api/test`, async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)) - return HttpResponse.json({ id: 123 }) - }), - ) - - const fetcher = createFetcher() - const schema = z.object({ id: z.number() }) - const controller = new AbortController() - - setTimeout(() => controller.abort(), 10) - - const result = await fetcher.get("/api/test", schema, { signal: controller.signal }) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.code).toBe(0) - expect(result.error.message).toBe("Request aborted") - expect(result.error.aborted).toBe(true) - } - }) - - it("handles already aborted signal", async () => { - server.use( - http.get(`${BASE_URL}/api/test`, async () => { - return HttpResponse.json({ id: 123 }) - }), - ) - - const fetcher = createFetcher() - const schema = z.object({ id: z.number() }) - const controller = new AbortController() - controller.abort() - - const result = await fetcher.get("/api/test", schema, { signal: controller.signal }) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.aborted).toBe(true) - } - }) - - it("returns network error on fetch failure", async () => { - server.use( - http.get(`${BASE_URL}/api/test`, () => { - return HttpResponse.error() - }), - ) - - const fetcher = createFetcher() - const schema = z.object({ id: z.number() }) - const result = await fetcher.get("/api/test", schema) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.code).toBe(0) - expect(result.error.reason).toBeDefined() - } - }) - - it("times out when request takes too long", async () => { - server.use( - http.get(`${BASE_URL}/api/test`, async () => { - await new Promise((resolve) => setTimeout(resolve, 200)) - return HttpResponse.json({ id: 123 }) - }), - ) - - const fetcher = createFetcher({ timeout: 50 }) - const schema = z.object({ id: z.number() }) - const result = await fetcher.get("/api/test", schema) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.aborted).toBe(true) - } - }) -}) - -describe("fetcher.post", () => { - it("sends JSON body and returns parsed response", async () => { - let capturedBody: unknown - server.use( - http.post(`${BASE_URL}/api/create`, async ({ request }) => { - capturedBody = await request.json() - return HttpResponse.json({ id: 456, success: true }) - }), - ) - - const fetcher = createFetcher() - const schema = z.object({ id: z.number(), success: z.boolean() }) - const result = await fetcher.post("/api/create", { name: "new item" }, schema) - - expect(capturedBody).toEqual({ name: "new item" }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.id).toBe(456) - expect(result.data.success).toBe(true) - } - }) - - it("handles HTTP error in POST", async () => { - server.use( - http.post(`${BASE_URL}/api/create`, () => { - return HttpResponse.json({ error: "Bad Request" }, { status: 400 }) - }), - ) - - const fetcher = createFetcher() - const schema = z.object({ id: z.number() }) - const result = await fetcher.post("/api/create", {}, schema) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.code).toBe(400) - expect(result.error.message).toBe("Bad Request") - } - }) -}) - -describe("fetcher.delete", () => { - it("sends DELETE request and returns parsed response", async () => { - server.use( - http.delete(`${BASE_URL}/api/items/123`, () => { - return HttpResponse.json({ deleted: true }) - }), - ) - - const fetcher = createFetcher() - const schema = z.object({ deleted: z.boolean() }) - const result = await fetcher.delete("/api/items/123", schema) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.deleted).toBe(true) - } - }) - - it("handles HTTP error in DELETE", async () => { - server.use( - http.delete(`${BASE_URL}/api/items/999`, () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }), - ) - - const fetcher = createFetcher() - const schema = z.object({ deleted: z.boolean() }) - const result = await fetcher.delete("/api/items/999", schema) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.code).toBe(404) - } - }) -}) - -describe("fetcher.getBlob", () => { - it("returns blob on success", async () => { - const blobData = new Uint8Array([1, 2, 3, 4, 5]) - server.use( - http.get(`${BASE_URL}/api/download`, () => { - return new HttpResponse(blobData, { - headers: { "Content-Type": "application/octet-stream" }, - }) - }), - ) - - const fetcher = createFetcher() - const result = await fetcher.getBlob("/api/download") - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toBeInstanceOf(Blob) - const buffer = await result.data.arrayBuffer() - expect(new Uint8Array(buffer)).toEqual(blobData) - } - }) - - it("returns error on HTTP error", async () => { - server.use( - http.get(`${BASE_URL}/api/download`, () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }), - ) - - const fetcher = createFetcher() - const result = await fetcher.getBlob("/api/download") - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.code).toBe(404) - } - }) - - it("returns error on HTTP error without JSON body", async () => { - server.use( - http.get(`${BASE_URL}/api/download`, () => { - return new HttpResponse("Server Error", { status: 500 }) - }), - ) - - const fetcher = createFetcher() - const result = await fetcher.getBlob("/api/download") - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.code).toBe(500) - } - }) - - it("returns abort error on request abort", async () => { - server.use( - http.get(`${BASE_URL}/api/download`, async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)) - return new HttpResponse(new Uint8Array([1, 2, 3])) - }), - ) - - const fetcher = createFetcher() - const controller = new AbortController() - - setTimeout(() => controller.abort(), 10) - - const result = await fetcher.getBlob("/api/download", { signal: controller.signal }) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.aborted).toBe(true) - } - }) - - it("returns network error on fetch failure", async () => { - server.use( - http.get(`${BASE_URL}/api/download`, () => { - return HttpResponse.error() - }), - ) - - const fetcher = createFetcher() - const result = await fetcher.getBlob("/api/download") - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.code).toBe(0) - } - }) -}) diff --git a/packages/api-client/src/fetcher.ts b/packages/api-client/src/fetcher.ts deleted file mode 100644 index c76700d4..00000000 --- a/packages/api-client/src/fetcher.ts +++ /dev/null @@ -1,204 +0,0 @@ -import type { ZodType } from "zod" -import type { ApiClientConfig, ApiError, ApiResult, RequestOptions } from "./types.js" - -const DEFAULT_BASE_URL = "https://api.perstack.ai" -const DEFAULT_TIMEOUT = 30000 - -export interface Fetcher { - get(path: string, schema: ZodType, options?: RequestOptions): Promise> - post( - path: string, - body: unknown, - schema: ZodType, - options?: RequestOptions, - ): Promise> - delete(path: string, schema: ZodType, options?: RequestOptions): Promise> - getBlob(path: string, options?: RequestOptions): Promise> -} - -function createAbortError(): ApiError { - return { - code: 0, - message: "Request aborted", - aborted: true, - } -} - -function createNetworkError(error: unknown): ApiError { - return { - code: 0, - message: error instanceof Error ? error.message : "Network error", - reason: error, - } -} - -function createHttpError(status: number, statusText: string, body?: unknown): ApiError { - if (typeof body === "object" && body !== null) { - const hasReason = "reason" in body - - if ("error" in body && typeof body.error === "string") { - const errorMessage: string = body.error - return { - code: status, - message: errorMessage, - reason: hasReason ? body.reason : undefined, - } - } - - // Preserve reason even if error field is missing or not a string - if (hasReason) { - return { - code: status, - message: statusText, - reason: body.reason, - } - } - } - return { - code: status, - message: statusText, - } -} - -function createValidationError(error: unknown): ApiError { - return { - code: 0, - message: "Response validation failed", - reason: error, - } -} - -export function createFetcher(config?: ApiClientConfig): Fetcher { - const baseUrl = config?.baseUrl ?? DEFAULT_BASE_URL - const timeout = config?.timeout ?? DEFAULT_TIMEOUT - const apiKey = config?.apiKey - - function buildUrl(path: string): string { - return `${baseUrl}${path}` - } - - function buildHeaders(additionalHeaders?: Record): Record { - const headers: Record = { - "Content-Type": "application/json", - ...additionalHeaders, - } - if (apiKey) { - headers.Authorization = `Bearer ${apiKey}` - } - return headers - } - - function createTimeoutSignal(externalSignal?: AbortSignal): { - signal: AbortSignal - cleanup: () => void - } { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), timeout) - - if (externalSignal) { - if (externalSignal.aborted) { - controller.abort() - } else { - externalSignal.addEventListener("abort", () => controller.abort()) - } - } - - return { - signal: controller.signal, - cleanup: () => clearTimeout(timeoutId), - } - } - - async function request( - method: string, - path: string, - schema: ZodType, - body?: unknown, - options?: RequestOptions, - ): Promise> { - const { signal, cleanup } = createTimeoutSignal(options?.signal) - - try { - const response = await fetch(buildUrl(path), { - method, - headers: buildHeaders(), - body: body ? JSON.stringify(body) : undefined, - signal, - }) - - if (!response.ok) { - let errorBody: unknown - try { - errorBody = await response.json() - } catch { - errorBody = undefined - } - return { - ok: false, - error: createHttpError(response.status, response.statusText, errorBody), - } - } - - const json = await response.json() - const parsed = schema.safeParse(json) - - if (!parsed.success) { - return { ok: false, error: createValidationError(parsed.error) } - } - - return { ok: true, data: parsed.data } - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - return { ok: false, error: createAbortError() } - } - return { ok: false, error: createNetworkError(error) } - } finally { - cleanup() - } - } - - async function requestBlob(path: string, options?: RequestOptions): Promise> { - const { signal, cleanup } = createTimeoutSignal(options?.signal) - - try { - const response = await fetch(buildUrl(path), { - method: "GET", - headers: buildHeaders(), - signal, - }) - - if (!response.ok) { - let errorBody: unknown - try { - errorBody = await response.json() - } catch { - errorBody = undefined - } - return { - ok: false, - error: createHttpError(response.status, response.statusText, errorBody), - } - } - - const blob = await response.blob() - return { ok: true, data: blob } - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - return { ok: false, error: createAbortError() } - } - return { ok: false, error: createNetworkError(error) } - } finally { - cleanup() - } - } - - return { - get: (path: string, schema: ZodType, options?: RequestOptions) => - request("GET", path, schema, undefined, options), - post: (path: string, body: unknown, schema: ZodType, options?: RequestOptions) => - request("POST", path, schema, body, options), - delete: (path: string, schema: ZodType, options?: RequestOptions) => - request("DELETE", path, schema, undefined, options), - getBlob: (path: string, options?: RequestOptions) => requestBlob(path, options), - } -} diff --git a/packages/api-client/src/index.test.ts b/packages/api-client/src/index.test.ts deleted file mode 100644 index f3e22889..00000000 --- a/packages/api-client/src/index.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from "vitest" -import * as ApiClient from "./index.js" - -describe("@perstack/api-client exports", () => { - describe("main exports", () => { - it("exports createApiClient", () => { - expect(ApiClient.createApiClient).toBeInstanceOf(Function) - }) - - it("exports createFetcher", () => { - expect(ApiClient.createFetcher).toBeInstanceOf(Function) - }) - }) - - describe("registry exports", () => { - it("exports createRegistryExpertsApi", () => { - expect(ApiClient.createRegistryExpertsApi).toBeInstanceOf(Function) - }) - }) - - describe("studio exports", () => { - it("exports createStudioExpertsApi", () => { - expect(ApiClient.createStudioExpertsApi).toBeInstanceOf(Function) - }) - - it("exports createStudioExpertJobsApi", () => { - expect(ApiClient.createStudioExpertJobsApi).toBeInstanceOf(Function) - }) - - it("exports createStudioWorkspaceApi", () => { - expect(ApiClient.createStudioWorkspaceApi).toBeInstanceOf(Function) - }) - }) -}) diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts deleted file mode 100644 index 4da20656..00000000 --- a/packages/api-client/src/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Core exports -export { type ApiClient, createApiClient } from "./client.js" -export { createFetcher, type Fetcher } from "./fetcher.js" -// Registry exports -export { createRegistryExpertsApi, type RegistryExpertsApi } from "./registry/experts.js" -export type { - CreateExpertInput, - ExpertDigest as RegistryExpertDigest, - ListExpertsParams, - Owner as RegistryOwner, - RegistryExpert, - RegistryExpertStatus, - UpdateExpertInput, -} from "./registry/types.js" -export { - type CheckpointsApi, - createStudioExpertJobsApi, - type StudioExpertJobsApi, - type WorkspaceInstanceApi, - type WorkspaceInstanceItemsApi, -} from "./studio/expert-jobs.js" - -// Studio exports -export { createStudioExpertsApi, type StudioExpertsApi } from "./studio/experts.js" -export type { - Application, - Checkpoint, - ContinueExpertJobInput, - CreateStudioExpertInput, - CreateWorkspaceSecretInput, - CreateWorkspaceVariableInput, - ExpertDigest, - ExpertJob, - ExpertJobStatus, - ListCheckpointsParams, - ListExpertJobsParams, - ListStudioExpertsParams, - ListWorkspaceItemsParams, - Owner, - StartExpertJobInput, - StudioExpert, - UpdateExpertJobInput, - UpdateStudioExpertInput, - UpdateWorkspaceVariableInput, - Workspace, - WorkspaceInstance, - WorkspaceItem, - WorkspaceSecret, - WorkspaceVariable, -} from "./studio/types.js" -export { - createStudioWorkspaceApi, - type StudioWorkspaceApi, - type WorkspaceItemsApi, - type WorkspaceSecretsApi, - type WorkspaceVariablesApi, -} from "./studio/workspace.js" -export type { - ApiClientConfig, - ApiError, - ApiResult, - PaginatedResult, - PaginationParams, - RequestOptions, -} from "./types.js" diff --git a/packages/api-client/src/registry/experts.test.ts b/packages/api-client/src/registry/experts.test.ts deleted file mode 100644 index 7aab0e31..00000000 --- a/packages/api-client/src/registry/experts.test.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { HttpResponse, http } from "msw" -import { setupServer } from "msw/node" -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest" -import { createFetcher } from "../fetcher.js" -import { createRegistryExpertsApi } from "./experts.js" - -const server = setupServer() - -beforeAll(() => server.listen({ onUnhandledRequest: "error" })) -afterEach(() => server.resetHandlers()) -afterAll(() => server.close()) - -const BASE_URL = "https://api.perstack.ai" - -const createMockExpert = (overrides = {}) => ({ - type: "registryExpert" as const, - id: "test-id", - key: "my-expert@1.0.0", - name: "my-expert", - minRuntimeVersion: "v1.0" as const, - description: "Test expert", - owner: { - name: "test-org", - organizationId: "org123456789012345678901", - createdAt: "2024-01-01T00:00:00Z", - }, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - version: "1.0.0", - status: "available" as const, - instruction: "Test instruction", - skills: {}, - delegates: [], - tags: ["latest"], - ...overrides, -}) - -describe("createRegistryExpertsApi", () => { - const fetcher = createFetcher() - const api = createRegistryExpertsApi(fetcher) - - describe("get", () => { - it("returns expert on success", async () => { - const mockExpert = createMockExpert() - server.use( - http.get(`${BASE_URL}/api/registry/v1/experts/:key`, () => { - return HttpResponse.json({ data: { expert: mockExpert } }) - }), - ) - - const result = await api.get("my-expert@1.0.0") - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.name).toBe("my-expert") - expect(result.data.createdAt).toBeInstanceOf(Date) - } - }) - - it("properly encodes expertKey in URL", async () => { - let capturedUrl = "" - server.use( - http.get(`${BASE_URL}/api/registry/v1/experts/:key`, ({ request }) => { - capturedUrl = request.url - return HttpResponse.json({ data: { expert: createMockExpert() } }) - }), - ) - - await api.get("@perstack/base@1.0.0") - expect(capturedUrl).toContain("%40perstack%2Fbase%401.0.0") - }) - - it("returns error on failure", async () => { - server.use( - http.get(`${BASE_URL}/api/registry/v1/experts/:key`, () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }), - ) - - const result = await api.get("not-found") - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.code).toBe(404) - } - }) - }) - - describe("list", () => { - it("returns experts on success", async () => { - const mockExperts = [createMockExpert()] - server.use( - http.get(`${BASE_URL}/api/registry/v1/experts/`, () => { - return HttpResponse.json({ - data: { experts: mockExperts }, - meta: { total: 1, take: 100, skip: 0 }, - }) - }), - ) - - const result = await api.list() - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.data).toHaveLength(1) - expect(result.data.meta.total).toBe(1) - } - }) - - it("includes query parameters when provided", async () => { - let capturedUrl = "" - server.use( - http.get(`${BASE_URL}/api/registry/v1/experts/`, ({ request }) => { - capturedUrl = request.url - return HttpResponse.json({ - data: { experts: [] }, - meta: { total: 0, take: 50, skip: 10 }, - }) - }), - ) - - await api.list({ - organizationId: "org123", - filter: "test", - sort: "name", - order: "asc", - take: 50, - skip: 10, - }) - - expect(capturedUrl).toContain("organizationId=org123") - expect(capturedUrl).toContain("filter=test") - expect(capturedUrl).toContain("sort=name") - expect(capturedUrl).toContain("order=asc") - expect(capturedUrl).toContain("take=50") - expect(capturedUrl).toContain("skip=10") - }) - - it("omits query parameters when not provided", async () => { - let capturedUrl = "" - server.use( - http.get(`${BASE_URL}/api/registry/v1/experts/`, ({ request }) => { - capturedUrl = request.url - return HttpResponse.json({ - data: { experts: [] }, - meta: { total: 0, take: 100, skip: 0 }, - }) - }), - ) - - await api.list() - expect(capturedUrl).not.toContain("filter=") - expect(capturedUrl).not.toContain("sort=") - }) - - it("returns error on failure", async () => { - server.use( - http.get(`${BASE_URL}/api/registry/v1/experts/`, () => { - return HttpResponse.json({ error: "Unauthorized" }, { status: 401 }) - }), - ) - - const result = await api.list() - expect(result.ok).toBe(false) - }) - }) - - describe("create", () => { - it("creates expert on success", async () => { - let capturedBody: unknown - server.use( - http.post(`${BASE_URL}/api/registry/v1/experts/`, async ({ request }) => { - capturedBody = await request.json() - return HttpResponse.json({ data: { expert: createMockExpert() } }) - }), - ) - - const result = await api.create({ - name: "my-expert", - version: "1.0.0", - minRuntimeVersion: "v1.0", - description: "Test", - instruction: "Test instruction", - skills: {}, - }) - - expect(capturedBody).toEqual({ - name: "my-expert", - version: "1.0.0", - minRuntimeVersion: "v1.0", - description: "Test", - instruction: "Test instruction", - skills: {}, - }) - expect(result.ok).toBe(true) - }) - - it("returns error on failure", async () => { - server.use( - http.post(`${BASE_URL}/api/registry/v1/experts/`, () => { - return HttpResponse.json({ error: "Bad Request" }, { status: 400 }) - }), - ) - - const result = await api.create({ - name: "my-expert", - version: "1.0.0", - minRuntimeVersion: "v1.0", - description: "Test", - instruction: "Test instruction", - skills: {}, - }) - - expect(result.ok).toBe(false) - }) - }) - - describe("update", () => { - it("updates expert on success", async () => { - let capturedBody: unknown - server.use( - http.post(`${BASE_URL}/api/registry/v1/experts/:key`, async ({ request }) => { - capturedBody = await request.json() - return HttpResponse.json({ data: { expert: createMockExpert({ status: "deprecated" }) } }) - }), - ) - - const result = await api.update("my-expert@1.0.0", { status: "deprecated" }) - - expect(capturedBody).toEqual({ status: "deprecated" }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.status).toBe("deprecated") - } - }) - - it("returns error on failure", async () => { - server.use( - http.post(`${BASE_URL}/api/registry/v1/experts/:key`, () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }), - ) - - const result = await api.update("not-found", { status: "deprecated" }) - expect(result.ok).toBe(false) - }) - }) - - describe("delete", () => { - it("deletes expert on success", async () => { - let capturedUrl = "" - server.use( - http.delete(`${BASE_URL}/api/registry/v1/experts/:key`, ({ request }) => { - capturedUrl = request.url - return HttpResponse.json({}) - }), - ) - - const result = await api.delete("my-expert@1.0.0") - - expect(capturedUrl).toContain("my-expert%401.0.0") - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toBeUndefined() - } - }) - - it("returns error on failure", async () => { - server.use( - http.delete(`${BASE_URL}/api/registry/v1/experts/:key`, () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }), - ) - - const result = await api.delete("not-found") - expect(result.ok).toBe(false) - }) - }) - - describe("getVersions", () => { - it("returns versions on success", async () => { - const mockVersions = [ - { - type: "expertDigest" as const, - id: "id-1", - key: "my-expert@1.0.0", - name: "my-expert", - minRuntimeVersion: "v1.0" as const, - description: "Test", - owner: { organizationId: "org123", createdAt: "2024-01-01T00:00:00Z" }, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - version: "1.0.0", - tags: ["latest"], - }, - ] - server.use( - http.get(`${BASE_URL}/api/registry/v1/experts/:key/versions`, () => { - return HttpResponse.json({ - data: { versions: mockVersions, latest: "1.0.0" }, - meta: { total: 1 }, - }) - }), - ) - - const result = await api.getVersions("my-expert") - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.versions).toHaveLength(1) - expect(result.data.latest).toBe("1.0.0") - expect(result.data.total).toBe(1) - } - }) - - it("returns error on failure", async () => { - server.use( - http.get(`${BASE_URL}/api/registry/v1/experts/:key/versions`, () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }), - ) - - const result = await api.getVersions("not-found") - expect(result.ok).toBe(false) - }) - }) -}) diff --git a/packages/api-client/src/registry/experts.ts b/packages/api-client/src/registry/experts.ts deleted file mode 100644 index 962867d8..00000000 --- a/packages/api-client/src/registry/experts.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * Registry Experts API - * - * @see docs/api-reference/registry-v1/experts.md - */ -import type { Fetcher } from "../fetcher.js" -import type { ApiResult, PaginatedResult, RequestOptions } from "../types.js" -import { - type CreateExpertInput, - createExpertResponseSchema, - deleteExpertResponseSchema, - type ExpertDigest, - getExpertResponseSchema, - getExpertVersionsResponseSchema, - type ListExpertsParams, - listExpertsResponseSchema, - type RegistryExpert, - type UpdateExpertInput, - updateExpertResponseSchema, -} from "./types.js" - -export interface RegistryExpertsApi { - get(key: string, options?: RequestOptions): Promise> - list( - params?: ListExpertsParams, - options?: RequestOptions, - ): Promise>> - create(input: CreateExpertInput, options?: RequestOptions): Promise> - update( - key: string, - input: UpdateExpertInput, - options?: RequestOptions, - ): Promise> - delete(key: string, options?: RequestOptions): Promise> - getVersions( - key: string, - options?: RequestOptions, - ): Promise> -} - -export function createRegistryExpertsApi(fetcher: Fetcher): RegistryExpertsApi { - return { - async get(key, options) { - const result = await fetcher.get( - `/api/registry/v1/experts/${encodeURIComponent(key)}`, - getExpertResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: result.data.data.expert } - }, - - async list(params, options) { - const searchParams = new URLSearchParams() - if (params?.organizationId) searchParams.set("organizationId", params.organizationId) - if (params?.filter) searchParams.set("filter", params.filter) - if (params?.sort) searchParams.set("sort", params.sort) - if (params?.order) searchParams.set("order", params.order) - if (params?.take !== undefined) searchParams.set("take", params.take.toString()) - if (params?.skip !== undefined) searchParams.set("skip", params.skip.toString()) - - const query = searchParams.toString() - const path = `/api/registry/v1/experts/${query ? `?${query}` : ""}` - - const result = await fetcher.get(path, listExpertsResponseSchema, options) - if (!result.ok) return result - return { - ok: true, - data: { - data: result.data.data.experts, - meta: result.data.meta, - }, - } - }, - - async create(input, options) { - const result = await fetcher.post( - "/api/registry/v1/experts/", - input, - createExpertResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: result.data.data.expert } - }, - - async update(key, input, options) { - const result = await fetcher.post( - `/api/registry/v1/experts/${encodeURIComponent(key)}`, - input, - updateExpertResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: result.data.data.expert } - }, - - async delete(key, options) { - const result = await fetcher.delete( - `/api/registry/v1/experts/${encodeURIComponent(key)}`, - deleteExpertResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: undefined } - }, - - async getVersions(key, options) { - const result = await fetcher.get( - `/api/registry/v1/experts/${encodeURIComponent(key)}/versions`, - getExpertVersionsResponseSchema, - options, - ) - if (!result.ok) return result - return { - ok: true, - data: { - versions: result.data.data.versions, - latest: result.data.data.latest, - total: result.data.meta.total, - }, - } - }, - } -} diff --git a/packages/api-client/src/registry/types.test.ts b/packages/api-client/src/registry/types.test.ts deleted file mode 100644 index e518deef..00000000 --- a/packages/api-client/src/registry/types.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { describe, expect, it } from "vitest" -import { - createExpertResponseSchema, - deleteExpertResponseSchema, - expertDigestSchema, - getExpertResponseSchema, - getExpertVersionsResponseSchema, - listExpertsResponseSchema, - ownerSchema, - registryExpertSchema, - registryExpertStatusSchema, - updateExpertResponseSchema, -} from "./types.js" - -describe("registryExpertStatusSchema", () => { - it("accepts valid status values", () => { - expect(registryExpertStatusSchema.parse("available")).toBe("available") - expect(registryExpertStatusSchema.parse("deprecated")).toBe("deprecated") - expect(registryExpertStatusSchema.parse("disabled")).toBe("disabled") - }) - - it("rejects invalid status values", () => { - expect(() => registryExpertStatusSchema.parse("invalid")).toThrow() - }) -}) - -describe("ownerSchema", () => { - it("parses valid owner with name", () => { - const owner = ownerSchema.parse({ - name: "test-org", - organizationId: "org123456789", - createdAt: "2024-01-01T00:00:00Z", - }) - expect(owner.name).toBe("test-org") - expect(owner.organizationId).toBe("org123456789") - expect(owner.createdAt).toBeInstanceOf(Date) - }) - - it("parses valid owner without name", () => { - const owner = ownerSchema.parse({ - organizationId: "org123456789", - createdAt: "2024-01-01T00:00:00Z", - }) - expect(owner.name).toBeUndefined() - expect(owner.organizationId).toBe("org123456789") - }) - - it("transforms createdAt to Date", () => { - const owner = ownerSchema.parse({ - organizationId: "org123", - createdAt: "2024-06-15T12:30:45Z", - }) - expect(owner.createdAt).toBeInstanceOf(Date) - expect(owner.createdAt.toISOString()).toBe("2024-06-15T12:30:45.000Z") - }) -}) - -describe("registryExpertSchema", () => { - const validExpert = { - type: "registryExpert", - id: "test-id", - key: "my-expert@1.0.0", - name: "my-expert", - minRuntimeVersion: "v1.0", - description: "Test expert", - owner: { - organizationId: "org123", - createdAt: "2024-01-01T00:00:00Z", - }, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - version: "1.0.0", - status: "available", - instruction: "Test instruction", - skills: {}, - delegates: [], - tags: ["latest"], - } - - it("parses valid expert", () => { - const expert = registryExpertSchema.parse(validExpert) - expect(expert.type).toBe("registryExpert") - expect(expert.name).toBe("my-expert") - expect(expert.createdAt).toBeInstanceOf(Date) - expect(expert.updatedAt).toBeInstanceOf(Date) - }) - - it("transforms skills to Record", () => { - const expertWithSkills = { - ...validExpert, - skills: { - tool1: { type: "mcpStdioSkill", description: "Test" }, - }, - } - const expert = registryExpertSchema.parse(expertWithSkills) - expect(expert.skills).toEqual({ tool1: { type: "mcpStdioSkill", description: "Test" } }) - }) - - it("rejects invalid type", () => { - expect(() => registryExpertSchema.parse({ ...validExpert, type: "invalid" })).toThrow() - }) - - it("rejects invalid minRuntimeVersion", () => { - expect(() => - registryExpertSchema.parse({ ...validExpert, minRuntimeVersion: "v2.0" }), - ).toThrow() - }) -}) - -describe("expertDigestSchema", () => { - const validDigest = { - type: "expertDigest", - id: "test-id", - key: "my-expert@1.0.0", - name: "my-expert", - minRuntimeVersion: "v1.0", - description: "Test expert", - owner: { - organizationId: "org123", - createdAt: "2024-01-01T00:00:00Z", - }, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - tags: [], - } - - it("parses valid digest", () => { - const digest = expertDigestSchema.parse(validDigest) - expect(digest.type).toBe("expertDigest") - expect(digest.version).toBeUndefined() - }) - - it("parses digest with version", () => { - const digest = expertDigestSchema.parse({ ...validDigest, version: "1.0.0" }) - expect(digest.version).toBe("1.0.0") - }) -}) - -describe("response schemas", () => { - const mockExpert = { - type: "registryExpert", - id: "test-id", - key: "my-expert@1.0.0", - name: "my-expert", - minRuntimeVersion: "v1.0", - description: "Test", - owner: { organizationId: "org123", createdAt: "2024-01-01T00:00:00Z" }, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - version: "1.0.0", - status: "available", - instruction: "Test", - skills: {}, - delegates: [], - tags: [], - } - - describe("getExpertResponseSchema", () => { - it("parses valid response", () => { - const response = getExpertResponseSchema.parse({ - data: { expert: mockExpert }, - }) - expect(response.data.expert.name).toBe("my-expert") - }) - }) - - describe("listExpertsResponseSchema", () => { - it("parses valid response", () => { - const response = listExpertsResponseSchema.parse({ - data: { experts: [mockExpert] }, - meta: { total: 1, take: 100, skip: 0 }, - }) - expect(response.data.experts).toHaveLength(1) - expect(response.meta.total).toBe(1) - }) - }) - - describe("createExpertResponseSchema", () => { - it("parses valid response", () => { - const response = createExpertResponseSchema.parse({ - data: { expert: mockExpert }, - }) - expect(response.data.expert.name).toBe("my-expert") - }) - }) - - describe("updateExpertResponseSchema", () => { - it("parses valid response", () => { - const response = updateExpertResponseSchema.parse({ - data: { expert: mockExpert }, - }) - expect(response.data.expert.name).toBe("my-expert") - }) - }) - - describe("deleteExpertResponseSchema", () => { - it("parses empty response", () => { - const response = deleteExpertResponseSchema.parse({}) - expect(response).toEqual({}) - }) - }) - - describe("getExpertVersionsResponseSchema", () => { - it("parses valid response", () => { - const digest = { - type: "expertDigest", - id: "id-1", - key: "my-expert@1.0.0", - name: "my-expert", - minRuntimeVersion: "v1.0", - description: "Test", - owner: { organizationId: "org123", createdAt: "2024-01-01T00:00:00Z" }, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - version: "1.0.0", - tags: ["latest"], - } - const response = getExpertVersionsResponseSchema.parse({ - data: { versions: [digest], latest: "1.0.0" }, - meta: { total: 1 }, - }) - expect(response.data.versions).toHaveLength(1) - expect(response.data.latest).toBe("1.0.0") - expect(response.meta.total).toBe(1) - }) - }) -}) diff --git a/packages/api-client/src/registry/types.ts b/packages/api-client/src/registry/types.ts deleted file mode 100644 index 93a9bc92..00000000 --- a/packages/api-client/src/registry/types.ts +++ /dev/null @@ -1,112 +0,0 @@ -import type { Skill } from "@perstack/core" -import { z } from "zod" - -export const registryExpertStatusSchema = z.enum(["available", "deprecated", "disabled"]) -export type RegistryExpertStatus = z.infer - -export const ownerSchema = z.object({ - name: z.string().optional(), - organizationId: z.string(), - createdAt: z.string().transform((s) => new Date(s)), -}) -export type Owner = z.infer - -export const registryExpertSchema = z.object({ - type: z.literal("registryExpert"), - id: z.string(), - key: z.string(), - name: z.string(), - minRuntimeVersion: z.literal("v1.0"), - description: z.string(), - owner: ownerSchema, - createdAt: z.string().transform((s) => new Date(s)), - updatedAt: z.string().transform((s) => new Date(s)), - version: z.string(), - status: registryExpertStatusSchema, - instruction: z.string(), - skills: z.record(z.string(), z.unknown()).transform((s) => s as Record), - delegates: z.array(z.string()), - tags: z.array(z.string()), -}) -export type RegistryExpert = z.infer - -export const expertDigestSchema = z.object({ - type: z.literal("expertDigest"), - id: z.string(), - key: z.string(), - name: z.string(), - minRuntimeVersion: z.literal("v1.0"), - description: z.string(), - owner: ownerSchema, - createdAt: z.string().transform((s) => new Date(s)), - updatedAt: z.string().transform((s) => new Date(s)), - version: z.string().optional(), - tags: z.array(z.string()), -}) -export type ExpertDigest = z.infer - -export const getExpertResponseSchema = z.object({ - data: z.object({ - expert: registryExpertSchema, - }), -}) - -export const listExpertsResponseSchema = z.object({ - data: z.object({ - experts: z.array(registryExpertSchema), - }), - meta: z.object({ - total: z.number(), - take: z.number(), - skip: z.number(), - }), -}) - -export const createExpertResponseSchema = z.object({ - data: z.object({ - expert: registryExpertSchema, - }), -}) - -export const updateExpertResponseSchema = z.object({ - data: z.object({ - expert: registryExpertSchema, - }), -}) - -export const deleteExpertResponseSchema = z.object({}) - -export const getExpertVersionsResponseSchema = z.object({ - data: z.object({ - versions: z.array(expertDigestSchema), - latest: z.string(), - }), - meta: z.object({ - total: z.number(), - }), -}) - -export interface CreateExpertInput { - name: string - version: string - minRuntimeVersion: "v1.0" - description: string - instruction: string - skills: Record - delegates?: string[] - tags?: string[] -} - -export interface UpdateExpertInput { - status?: RegistryExpertStatus - tags?: string[] -} - -export interface ListExpertsParams { - organizationId?: string - filter?: string - sort?: "name" | "version" | "createdAt" | "updatedAt" - order?: "asc" | "desc" - take?: number - skip?: number -} diff --git a/packages/api-client/src/studio/expert-jobs.test.ts b/packages/api-client/src/studio/expert-jobs.test.ts deleted file mode 100644 index a1e429ea..00000000 --- a/packages/api-client/src/studio/expert-jobs.test.ts +++ /dev/null @@ -1,630 +0,0 @@ -import { HttpResponse, http } from "msw" -import { setupServer } from "msw/node" -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest" -import { createFetcher } from "../fetcher.js" -import { createStudioExpertJobsApi } from "./expert-jobs.js" - -const server = setupServer() - -beforeAll(() => server.listen({ onUnhandledRequest: "error" })) -afterEach(() => server.resetHandlers()) -afterAll(() => server.close()) - -const BASE_URL = "https://api.perstack.ai" - -const createMockJob = (overrides = {}) => ({ - id: "job-123", - expertKey: "my-expert@1.0.0", - status: "queued" as const, - query: "Test query", - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - ...overrides, -}) - -const createMockCheckpoint = (overrides = {}) => ({ - id: "cp-123", - expertJobId: "job-123", - sequence: 1, - createdAt: "2024-01-01T00:00:00Z", - ...overrides, -}) - -const createMockWorkspaceInstance = (overrides = {}) => ({ - id: "wi-123", - expertJobId: "job-123", - createdAt: "2024-01-01T00:00:00Z", - ...overrides, -}) - -const createMockWorkspaceItem = (overrides = {}) => ({ - id: "item-123", - path: "/test.txt", - type: "file" as const, - size: 1024, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - ...overrides, -}) - -describe("createStudioExpertJobsApi", () => { - const fetcher = createFetcher() - const api = createStudioExpertJobsApi(fetcher) - - describe("get", () => { - it("returns job on success", async () => { - server.use( - http.get(`${BASE_URL}/api/studio/v1/expert_jobs/:id`, () => { - return HttpResponse.json({ data: { expertJob: createMockJob() } }) - }), - ) - - const result = await api.get("job-123") - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.id).toBe("job-123") - } - }) - - it("returns error on failure", async () => { - server.use( - http.get(`${BASE_URL}/api/studio/v1/expert_jobs/:id`, () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }), - ) - - const result = await api.get("not-found") - expect(result.ok).toBe(false) - }) - }) - - describe("list", () => { - it("returns jobs on success", async () => { - server.use( - http.get(`${BASE_URL}/api/studio/v1/expert_jobs/`, () => { - return HttpResponse.json({ - data: { expertJobs: [createMockJob()] }, - meta: { total: 1, take: 100, skip: 0 }, - }) - }), - ) - - const result = await api.list({ applicationId: "app-123" }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.data).toHaveLength(1) - } - }) - - it("includes query parameters", async () => { - let capturedUrl = "" - server.use( - http.get(`${BASE_URL}/api/studio/v1/expert_jobs/`, ({ request }) => { - capturedUrl = request.url - return HttpResponse.json({ - data: { expertJobs: [] }, - meta: { total: 0, take: 50, skip: 10 }, - }) - }), - ) - - await api.list({ - applicationId: "app-123", - filter: "test", - sort: "createdAt", - order: "desc", - take: 50, - skip: 10, - }) - - expect(capturedUrl).toContain("applicationId=app-123") - expect(capturedUrl).toContain("filter=test") - expect(capturedUrl).toContain("sort=createdAt") - expect(capturedUrl).toContain("order=desc") - }) - - it("returns error on failure", async () => { - server.use( - http.get(`${BASE_URL}/api/studio/v1/expert_jobs/`, () => { - return HttpResponse.json({ error: "Unauthorized" }, { status: 401 }) - }), - ) - - const result = await api.list({ applicationId: "app-123" }) - expect(result.ok).toBe(false) - }) - }) - - describe("start", () => { - it("starts job on success", async () => { - let capturedBody: unknown - server.use( - http.post(`${BASE_URL}/api/studio/v1/expert_jobs/`, async ({ request }) => { - capturedBody = await request.json() - return HttpResponse.json({ data: { expertJob: createMockJob() } }) - }), - ) - - const result = await api.start({ - expertKey: "my-expert@1.0.0", - query: "Test query", - }) - - expect(capturedBody).toEqual({ - expertKey: "my-expert@1.0.0", - query: "Test query", - }) - expect(result.ok).toBe(true) - }) - - it("returns error on failure", async () => { - server.use( - http.post(`${BASE_URL}/api/studio/v1/expert_jobs/`, () => { - return HttpResponse.json({ error: "Bad Request" }, { status: 400 }) - }), - ) - - const result = await api.start({ - expertKey: "my-expert", - query: "Test", - }) - expect(result.ok).toBe(false) - }) - }) - - describe("continue", () => { - it("continues job on success", async () => { - server.use( - http.post(`${BASE_URL}/api/studio/v1/expert_jobs/:id/continue`, () => { - return HttpResponse.json({ data: { expertJob: createMockJob({ status: "processing" }) } }) - }), - ) - - const result = await api.continue("job-123", { response: "User response" }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.status).toBe("processing") - } - }) - - it("returns error on failure", async () => { - server.use( - http.post(`${BASE_URL}/api/studio/v1/expert_jobs/:id/continue`, () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }), - ) - - const result = await api.continue("not-found", { response: "response" }) - expect(result.ok).toBe(false) - }) - }) - - describe("update", () => { - it("updates job on success", async () => { - server.use( - http.post(`${BASE_URL}/api/studio/v1/expert_jobs/:id`, () => { - return HttpResponse.json({ data: { expertJob: createMockJob({ status: "canceled" }) } }) - }), - ) - - const result = await api.update("job-123", { status: "canceled" }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.status).toBe("canceled") - } - }) - - it("returns error on failure", async () => { - server.use( - http.post(`${BASE_URL}/api/studio/v1/expert_jobs/:id`, () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }), - ) - - const result = await api.update("not-found", { status: "canceled" }) - expect(result.ok).toBe(false) - }) - }) - - describe("checkpoints", () => { - describe("get", () => { - it("returns checkpoint on success", async () => { - server.use( - http.get(`${BASE_URL}/api/studio/v1/expert_jobs/:jobId/checkpoints/:cpId`, () => { - return HttpResponse.json({ data: { checkpoint: createMockCheckpoint() } }) - }), - ) - - const result = await api.checkpoints.get("job-123", "cp-123") - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.id).toBe("cp-123") - } - }) - - it("returns error on failure", async () => { - server.use( - http.get(`${BASE_URL}/api/studio/v1/expert_jobs/:jobId/checkpoints/:cpId`, () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }), - ) - - const result = await api.checkpoints.get("job-123", "not-found") - expect(result.ok).toBe(false) - }) - }) - - describe("list", () => { - it("returns checkpoints on success", async () => { - server.use( - http.get(`${BASE_URL}/api/studio/v1/expert_jobs/:jobId/checkpoints/`, () => { - return HttpResponse.json({ - data: { checkpoints: [createMockCheckpoint()] }, - meta: { total: 1, take: 100, skip: 0 }, - }) - }), - ) - - const result = await api.checkpoints.list("job-123") - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.data).toHaveLength(1) - } - }) - - it("includes query parameters", async () => { - let capturedUrl = "" - server.use( - http.get(`${BASE_URL}/api/studio/v1/expert_jobs/:jobId/checkpoints/`, ({ request }) => { - capturedUrl = request.url - return HttpResponse.json({ - data: { checkpoints: [] }, - meta: { total: 0, take: 50, skip: 10 }, - }) - }), - ) - - await api.checkpoints.list("job-123", { - take: 50, - skip: 10, - sort: "sequence", - order: "asc", - }) - - expect(capturedUrl).toContain("take=50") - expect(capturedUrl).toContain("skip=10") - expect(capturedUrl).toContain("sort=sequence") - expect(capturedUrl).toContain("order=asc") - }) - - it("returns error on failure", async () => { - server.use( - http.get(`${BASE_URL}/api/studio/v1/expert_jobs/:jobId/checkpoints/`, () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }), - ) - - const result = await api.checkpoints.list("not-found") - expect(result.ok).toBe(false) - }) - }) - - describe("create", () => { - it("creates checkpoint on success", async () => { - server.use( - http.post(`${BASE_URL}/api/studio/v1/expert_jobs/:jobId/checkpoints/`, () => { - return HttpResponse.json({ data: { checkpoint: createMockCheckpoint() } }) - }), - ) - - const result = await api.checkpoints.create("job-123") - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.id).toBe("cp-123") - } - }) - - it("returns error on failure", async () => { - server.use( - http.post(`${BASE_URL}/api/studio/v1/expert_jobs/:jobId/checkpoints/`, () => { - return HttpResponse.json({ error: "Bad Request" }, { status: 400 }) - }), - ) - - const result = await api.checkpoints.create("not-found") - expect(result.ok).toBe(false) - }) - }) - }) - - describe("workspaceInstance", () => { - describe("get", () => { - it("returns workspace instance on success", async () => { - server.use( - http.get(`${BASE_URL}/api/studio/v1/expert_jobs/:jobId/workspace_instance/`, () => { - return HttpResponse.json({ - data: { workspaceInstance: createMockWorkspaceInstance() }, - }) - }), - ) - - const result = await api.workspaceInstance.get("job-123") - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.id).toBe("wi-123") - } - }) - - it("returns error on failure", async () => { - server.use( - http.get(`${BASE_URL}/api/studio/v1/expert_jobs/:jobId/workspace_instance/`, () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }), - ) - - const result = await api.workspaceInstance.get("not-found") - expect(result.ok).toBe(false) - }) - }) - - describe("items", () => { - describe("get", () => { - it("returns item on success", async () => { - server.use( - http.get( - `${BASE_URL}/api/studio/v1/expert_jobs/:jobId/workspace_instance/items/:itemId`, - () => { - return HttpResponse.json({ data: { item: createMockWorkspaceItem() } }) - }, - ), - ) - - const result = await api.workspaceInstance.items.get("job-123", "item-123") - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.id).toBe("item-123") - } - }) - - it("returns error on failure", async () => { - server.use( - http.get( - `${BASE_URL}/api/studio/v1/expert_jobs/:jobId/workspace_instance/items/:itemId`, - () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }, - ), - ) - - const result = await api.workspaceInstance.items.get("job-123", "not-found") - expect(result.ok).toBe(false) - }) - }) - - describe("list", () => { - it("returns items on success", async () => { - server.use( - http.get( - `${BASE_URL}/api/studio/v1/expert_jobs/:jobId/workspace_instance/items/`, - () => { - return HttpResponse.json({ - data: { workspaceItems: [createMockWorkspaceItem()] }, - meta: { total: 1, take: 100, skip: 0 }, - }) - }, - ), - ) - - const result = await api.workspaceInstance.items.list("job-123") - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.data).toHaveLength(1) - } - }) - - it("includes query parameters", async () => { - let capturedUrl = "" - server.use( - http.get( - `${BASE_URL}/api/studio/v1/expert_jobs/:jobId/workspace_instance/items/`, - ({ request }) => { - capturedUrl = request.url - return HttpResponse.json({ - data: { workspaceItems: [] }, - meta: { total: 0, take: 50, skip: 10 }, - }) - }, - ), - ) - - await api.workspaceInstance.items.list("job-123", { - take: 50, - skip: 10, - path: "/subdir", - }) - - expect(capturedUrl).toContain("take=50") - expect(capturedUrl).toContain("skip=10") - expect(capturedUrl).toContain("path=%2Fsubdir") - }) - - it("returns error on failure", async () => { - server.use( - http.get( - `${BASE_URL}/api/studio/v1/expert_jobs/:jobId/workspace_instance/items/`, - () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }, - ), - ) - - const result = await api.workspaceInstance.items.list("not-found") - expect(result.ok).toBe(false) - }) - }) - - describe("create", () => { - it("creates item on success", async () => { - server.use( - http.post( - `${BASE_URL}/api/studio/v1/expert_jobs/:jobId/workspace_instance/items/`, - () => { - return HttpResponse.json({ data: { item: createMockWorkspaceItem() } }) - }, - ), - ) - - const blob = new Blob(["test content"]) - const result = await api.workspaceInstance.items.create("job-123", "/test.txt", blob) - expect(result.ok).toBe(true) - }) - - it("returns error on failure", async () => { - server.use( - http.post( - `${BASE_URL}/api/studio/v1/expert_jobs/:jobId/workspace_instance/items/`, - () => { - return HttpResponse.json({ error: "Bad Request" }, { status: 400 }) - }, - ), - ) - - const blob = new Blob(["test"]) - const result = await api.workspaceInstance.items.create("job-123", "/test.txt", blob) - expect(result.ok).toBe(false) - }) - }) - - describe("update", () => { - it("updates item on success", async () => { - server.use( - http.post( - `${BASE_URL}/api/studio/v1/expert_jobs/:jobId/workspace_instance/items/:itemId`, - () => { - return HttpResponse.json({ data: { item: createMockWorkspaceItem() } }) - }, - ), - ) - - const blob = new Blob(["updated content"]) - const result = await api.workspaceInstance.items.update("job-123", "item-123", blob) - expect(result.ok).toBe(true) - }) - - it("returns error on failure", async () => { - server.use( - http.post( - `${BASE_URL}/api/studio/v1/expert_jobs/:jobId/workspace_instance/items/:itemId`, - () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }, - ), - ) - - const blob = new Blob(["test"]) - const result = await api.workspaceInstance.items.update("job-123", "not-found", blob) - expect(result.ok).toBe(false) - }) - }) - - describe("delete", () => { - it("deletes item on success", async () => { - server.use( - http.delete( - `${BASE_URL}/api/studio/v1/expert_jobs/:jobId/workspace_instance/items/:itemId`, - () => { - return HttpResponse.json({}) - }, - ), - ) - - const result = await api.workspaceInstance.items.delete("job-123", "item-123") - expect(result.ok).toBe(true) - }) - - it("returns error on failure", async () => { - server.use( - http.delete( - `${BASE_URL}/api/studio/v1/expert_jobs/:jobId/workspace_instance/items/:itemId`, - () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }, - ), - ) - - const result = await api.workspaceInstance.items.delete("job-123", "not-found") - expect(result.ok).toBe(false) - }) - }) - - describe("download", () => { - it("downloads item on success", async () => { - const blobData = new Uint8Array([1, 2, 3, 4, 5]) - server.use( - http.get( - `${BASE_URL}/api/studio/v1/expert_jobs/:jobId/workspace_instance/items/:itemId/download`, - () => { - return new HttpResponse(blobData, { - headers: { "Content-Type": "application/octet-stream" }, - }) - }, - ), - ) - - const result = await api.workspaceInstance.items.download("job-123", "item-123") - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toBeInstanceOf(Blob) - } - }) - - it("returns error on failure", async () => { - server.use( - http.get( - `${BASE_URL}/api/studio/v1/expert_jobs/:jobId/workspace_instance/items/:itemId/download`, - () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }, - ), - ) - - const result = await api.workspaceInstance.items.download("job-123", "not-found") - expect(result.ok).toBe(false) - }) - }) - - describe("find", () => { - it("finds items on success", async () => { - server.use( - http.get( - `${BASE_URL}/api/studio/v1/expert_jobs/:jobId/workspace_instance/items/find`, - () => { - return HttpResponse.json({ data: { workspaceItems: [createMockWorkspaceItem()] } }) - }, - ), - ) - - const result = await api.workspaceInstance.items.find("job-123", "*.txt") - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toHaveLength(1) - } - }) - - it("returns error on failure", async () => { - server.use( - http.get( - `${BASE_URL}/api/studio/v1/expert_jobs/:jobId/workspace_instance/items/find`, - () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }, - ), - ) - - const result = await api.workspaceInstance.items.find("not-found", "*") - expect(result.ok).toBe(false) - }) - }) - }) - }) -}) diff --git a/packages/api-client/src/studio/expert-jobs.ts b/packages/api-client/src/studio/expert-jobs.ts deleted file mode 100644 index 19409277..00000000 --- a/packages/api-client/src/studio/expert-jobs.ts +++ /dev/null @@ -1,317 +0,0 @@ -/** - * Studio Expert Jobs API - * - * @see docs/api-reference/studio-v1/expert-jobs.md - */ -import type { Fetcher } from "../fetcher.js" -import type { ApiResult, PaginatedResult, RequestOptions } from "../types.js" -import { blobToBase64 } from "../utils.js" -import { - type Checkpoint, - type ContinueExpertJobInput, - continueExpertJobResponseSchema, - createCheckpointResponseSchema, - createWorkspaceItemResponseSchema, - deleteWorkspaceItemResponseSchema, - type ExpertJob, - findWorkspaceItemsResponseSchema, - getCheckpointResponseSchema, - getExpertJobResponseSchema, - getWorkspaceInstanceResponseSchema, - getWorkspaceItemResponseSchema, - type ListCheckpointsParams, - type ListExpertJobsParams, - type ListWorkspaceItemsParams, - listCheckpointsResponseSchema, - listExpertJobsResponseSchema, - listWorkspaceItemsResponseSchema, - type StartExpertJobInput, - startExpertJobResponseSchema, - type UpdateExpertJobInput, - updateExpertJobResponseSchema, - updateWorkspaceItemResponseSchema, - type WorkspaceInstance, - type WorkspaceItem, -} from "./types.js" - -export interface StudioExpertJobsApi { - get(id: string, options?: RequestOptions): Promise> - list( - params: ListExpertJobsParams, - options?: RequestOptions, - ): Promise>> - start(input: StartExpertJobInput, options?: RequestOptions): Promise> - continue( - id: string, - input: ContinueExpertJobInput, - options?: RequestOptions, - ): Promise> - update( - id: string, - input: UpdateExpertJobInput, - options?: RequestOptions, - ): Promise> - checkpoints: CheckpointsApi - workspaceInstance: WorkspaceInstanceApi -} - -export interface CheckpointsApi { - get(jobId: string, checkpointId: string, options?: RequestOptions): Promise> - list( - jobId: string, - params?: ListCheckpointsParams, - options?: RequestOptions, - ): Promise>> - create(jobId: string, options?: RequestOptions): Promise> -} - -export interface WorkspaceInstanceApi { - get(jobId: string, options?: RequestOptions): Promise> - items: WorkspaceInstanceItemsApi -} - -export interface WorkspaceInstanceItemsApi { - get(jobId: string, itemId: string, options?: RequestOptions): Promise> - list( - jobId: string, - params?: ListWorkspaceItemsParams, - options?: RequestOptions, - ): Promise>> - create( - jobId: string, - path: string, - content: Blob, - options?: RequestOptions, - ): Promise> - update( - jobId: string, - itemId: string, - content: Blob, - options?: RequestOptions, - ): Promise> - delete(jobId: string, itemId: string, options?: RequestOptions): Promise> - download(jobId: string, itemId: string, options?: RequestOptions): Promise> - find( - jobId: string, - pattern: string, - options?: RequestOptions, - ): Promise> -} - -export function createStudioExpertJobsApi(fetcher: Fetcher): StudioExpertJobsApi { - return { - async get(id, options) { - const result = await fetcher.get( - `/api/studio/v1/expert_jobs/${encodeURIComponent(id)}`, - getExpertJobResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: result.data.data.expertJob } - }, - - async list(params, options) { - const searchParams = new URLSearchParams() - searchParams.set("applicationId", params.applicationId) - if (params.filter) searchParams.set("filter", params.filter) - if (params.sort) searchParams.set("sort", params.sort) - if (params.order) searchParams.set("order", params.order) - if (params.take !== undefined) searchParams.set("take", params.take.toString()) - if (params.skip !== undefined) searchParams.set("skip", params.skip.toString()) - - const path = `/api/studio/v1/expert_jobs/?${searchParams.toString()}` - - const result = await fetcher.get(path, listExpertJobsResponseSchema, options) - if (!result.ok) return result - return { - ok: true, - data: { - data: result.data.data.expertJobs, - meta: result.data.meta, - }, - } - }, - - async start(input, options) { - const result = await fetcher.post( - "/api/studio/v1/expert_jobs/", - input, - startExpertJobResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: result.data.data.expertJob } - }, - - async continue(id, input, options) { - const result = await fetcher.post( - `/api/studio/v1/expert_jobs/${encodeURIComponent(id)}/continue`, - input, - continueExpertJobResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: result.data.data.expertJob } - }, - - async update(id, input, options) { - const result = await fetcher.post( - `/api/studio/v1/expert_jobs/${encodeURIComponent(id)}`, - input, - updateExpertJobResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: result.data.data.expertJob } - }, - - checkpoints: createCheckpointsApi(fetcher), - workspaceInstance: createWorkspaceInstanceApi(fetcher), - } -} - -function createCheckpointsApi(fetcher: Fetcher): CheckpointsApi { - return { - async get(jobId, checkpointId, options) { - const result = await fetcher.get( - `/api/studio/v1/expert_jobs/${encodeURIComponent(jobId)}/checkpoints/${encodeURIComponent(checkpointId)}`, - getCheckpointResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: result.data.data.checkpoint } - }, - - async list(jobId, params, options) { - const searchParams = new URLSearchParams() - if (params?.take !== undefined) searchParams.set("take", params.take.toString()) - if (params?.skip !== undefined) searchParams.set("skip", params.skip.toString()) - if (params?.sort) searchParams.set("sort", params.sort) - if (params?.order) searchParams.set("order", params.order) - - const query = searchParams.toString() - const path = `/api/studio/v1/expert_jobs/${encodeURIComponent(jobId)}/checkpoints/${query ? `?${query}` : ""}` - - const result = await fetcher.get(path, listCheckpointsResponseSchema, options) - if (!result.ok) return result - return { - ok: true, - data: { - data: result.data.data.checkpoints, - meta: result.data.meta, - }, - } - }, - - async create(jobId, options) { - const result = await fetcher.post( - `/api/studio/v1/expert_jobs/${encodeURIComponent(jobId)}/checkpoints/`, - {}, - createCheckpointResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: result.data.data.checkpoint } - }, - } -} - -function createWorkspaceInstanceApi(fetcher: Fetcher): WorkspaceInstanceApi { - return { - async get(jobId, options) { - const result = await fetcher.get( - `/api/studio/v1/expert_jobs/${encodeURIComponent(jobId)}/workspace_instance/`, - getWorkspaceInstanceResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: result.data.data.workspaceInstance } - }, - - items: createWorkspaceInstanceItemsApi(fetcher), - } -} - -function createWorkspaceInstanceItemsApi(fetcher: Fetcher): WorkspaceInstanceItemsApi { - return { - async get(jobId, itemId, options) { - const result = await fetcher.get( - `/api/studio/v1/expert_jobs/${encodeURIComponent(jobId)}/workspace_instance/items/${encodeURIComponent(itemId)}`, - getWorkspaceItemResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: result.data.data.item } - }, - - async list(jobId, params, options) { - const searchParams = new URLSearchParams() - if (params?.take !== undefined) searchParams.set("take", params.take.toString()) - if (params?.skip !== undefined) searchParams.set("skip", params.skip.toString()) - if (params?.path) searchParams.set("path", params.path) - - const query = searchParams.toString() - const path = `/api/studio/v1/expert_jobs/${encodeURIComponent(jobId)}/workspace_instance/items/${query ? `?${query}` : ""}` - - const result = await fetcher.get(path, listWorkspaceItemsResponseSchema, options) - if (!result.ok) return result - return { - ok: true, - data: { - data: result.data.data.workspaceItems, - meta: result.data.meta, - }, - } - }, - - async create(jobId, itemPath, content, options) { - const result = await fetcher.post( - `/api/studio/v1/expert_jobs/${encodeURIComponent(jobId)}/workspace_instance/items/`, - { path: itemPath, content: await blobToBase64(content) }, - createWorkspaceItemResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: result.data.data.item } - }, - - async update(jobId, itemId, content, options) { - const result = await fetcher.post( - `/api/studio/v1/expert_jobs/${encodeURIComponent(jobId)}/workspace_instance/items/${encodeURIComponent(itemId)}`, - { content: await blobToBase64(content) }, - updateWorkspaceItemResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: result.data.data.item } - }, - - async delete(jobId, itemId, options) { - const result = await fetcher.delete( - `/api/studio/v1/expert_jobs/${encodeURIComponent(jobId)}/workspace_instance/items/${encodeURIComponent(itemId)}`, - deleteWorkspaceItemResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: undefined } - }, - - async download(jobId, itemId, options) { - return fetcher.getBlob( - `/api/studio/v1/expert_jobs/${encodeURIComponent(jobId)}/workspace_instance/items/${encodeURIComponent(itemId)}/download`, - options, - ) - }, - - async find(jobId, pathPattern, options) { - const searchParams = new URLSearchParams() - searchParams.set("path", pathPattern) - - const path = `/api/studio/v1/expert_jobs/${encodeURIComponent(jobId)}/workspace_instance/items/find?${searchParams.toString()}` - - const result = await fetcher.get(path, findWorkspaceItemsResponseSchema, options) - if (!result.ok) return result - return { ok: true, data: result.data.data.workspaceItems } - }, - } -} diff --git a/packages/api-client/src/studio/experts.test.ts b/packages/api-client/src/studio/experts.test.ts deleted file mode 100644 index c2717666..00000000 --- a/packages/api-client/src/studio/experts.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { HttpResponse, http } from "msw" -import { setupServer } from "msw/node" -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest" -import { createFetcher } from "../fetcher.js" -import { createStudioExpertsApi } from "./experts.js" - -const server = setupServer() - -beforeAll(() => server.listen({ onUnhandledRequest: "error" })) -afterEach(() => server.resetHandlers()) -afterAll(() => server.close()) - -const BASE_URL = "https://api.perstack.ai" - -const createMockStudioExpert = (overrides = {}) => ({ - type: "studioExpert" as const, - id: "test-id", - key: "my-expert", - name: "my-expert", - minRuntimeVersion: "v1.0" as const, - description: "Test expert", - owner: { - organizationId: "org123456789012345678901", - createdAt: "2024-01-01T00:00:00Z", - }, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - instruction: "Test instruction", - skills: {}, - delegates: [], - application: { id: "app-123", name: "test-app" }, - ...overrides, -}) - -const createMockExpertDigest = (overrides = {}) => ({ - type: "expertDigest" as const, - id: "test-id", - key: "my-expert", - name: "my-expert", - minRuntimeVersion: "v1.0" as const, - description: "Test expert", - owner: { - organizationId: "org123456789012345678901", - createdAt: "2024-01-01T00:00:00Z", - }, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - tags: [], - ...overrides, -}) - -describe("createStudioExpertsApi", () => { - const fetcher = createFetcher() - const api = createStudioExpertsApi(fetcher) - - describe("get", () => { - it("returns expert on success", async () => { - server.use( - http.get(`${BASE_URL}/api/studio/v1/experts/:key`, () => { - return HttpResponse.json({ data: { expert: createMockStudioExpert() } }) - }), - ) - - const result = await api.get("my-expert") - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.name).toBe("my-expert") - expect(result.data.type).toBe("studioExpert") - } - }) - - it("returns error on failure", async () => { - server.use( - http.get(`${BASE_URL}/api/studio/v1/experts/:key`, () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }), - ) - - const result = await api.get("not-found") - expect(result.ok).toBe(false) - }) - }) - - describe("list", () => { - it("returns experts on success", async () => { - server.use( - http.get(`${BASE_URL}/api/studio/v1/experts/`, () => { - return HttpResponse.json({ - data: { experts: [createMockExpertDigest()] }, - meta: { total: 1, take: 100, skip: 0 }, - }) - }), - ) - - const result = await api.list({ applicationId: "app-123" }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.data).toHaveLength(1) - expect(result.data.meta.total).toBe(1) - } - }) - - it("includes query parameters", async () => { - let capturedUrl = "" - server.use( - http.get(`${BASE_URL}/api/studio/v1/experts/`, ({ request }) => { - capturedUrl = request.url - return HttpResponse.json({ - data: { experts: [] }, - meta: { total: 0, take: 50, skip: 10 }, - }) - }), - ) - - await api.list({ - applicationId: "app-123", - filter: "test", - sort: "name", - order: "asc", - take: 50, - skip: 10, - }) - - expect(capturedUrl).toContain("applicationId=app-123") - expect(capturedUrl).toContain("filter=test") - expect(capturedUrl).toContain("sort=name") - expect(capturedUrl).toContain("order=asc") - expect(capturedUrl).toContain("take=50") - expect(capturedUrl).toContain("skip=10") - }) - - it("returns error on failure", async () => { - server.use( - http.get(`${BASE_URL}/api/studio/v1/experts/`, () => { - return HttpResponse.json({ error: "Unauthorized" }, { status: 401 }) - }), - ) - - const result = await api.list({ applicationId: "app-123" }) - expect(result.ok).toBe(false) - }) - }) - - describe("create", () => { - it("creates expert on success", async () => { - let capturedBody: unknown - server.use( - http.post(`${BASE_URL}/api/studio/v1/experts/`, async ({ request }) => { - capturedBody = await request.json() - return HttpResponse.json({ data: { expert: createMockStudioExpert() } }) - }), - ) - - const result = await api.create({ - name: "my-expert", - minRuntimeVersion: "v1.0", - description: "Test", - instruction: "Test instruction", - skills: {}, - delegates: [], - }) - - expect(capturedBody).toBeDefined() - expect(result.ok).toBe(true) - }) - - it("returns error on failure", async () => { - server.use( - http.post(`${BASE_URL}/api/studio/v1/experts/`, () => { - return HttpResponse.json({ error: "Bad Request" }, { status: 400 }) - }), - ) - - const result = await api.create({ - name: "my-expert", - minRuntimeVersion: "v1.0", - description: "Test", - instruction: "Test instruction", - skills: {}, - delegates: [], - }) - - expect(result.ok).toBe(false) - }) - }) - - describe("update", () => { - it("updates expert on success", async () => { - let capturedBody: unknown - server.use( - http.post(`${BASE_URL}/api/studio/v1/experts/:key`, async ({ request }) => { - capturedBody = await request.json() - return HttpResponse.json({ - data: { expert: createMockStudioExpert({ description: "Updated" }) }, - }) - }), - ) - - const result = await api.update("my-expert", { description: "Updated" }) - - expect(capturedBody).toEqual({ description: "Updated" }) - expect(result.ok).toBe(true) - }) - - it("returns error on failure", async () => { - server.use( - http.post(`${BASE_URL}/api/studio/v1/experts/:key`, () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }), - ) - - const result = await api.update("not-found", { description: "Updated" }) - expect(result.ok).toBe(false) - }) - }) - - describe("delete", () => { - it("deletes expert on success", async () => { - server.use( - http.delete(`${BASE_URL}/api/studio/v1/experts/:key`, () => { - return HttpResponse.json({}) - }), - ) - - const result = await api.delete("my-expert") - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toBeUndefined() - } - }) - - it("returns error on failure", async () => { - server.use( - http.delete(`${BASE_URL}/api/studio/v1/experts/:key`, () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }), - ) - - const result = await api.delete("not-found") - expect(result.ok).toBe(false) - }) - }) -}) diff --git a/packages/api-client/src/studio/experts.ts b/packages/api-client/src/studio/experts.ts deleted file mode 100644 index a9594d6b..00000000 --- a/packages/api-client/src/studio/experts.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Studio Experts API - * - * @see docs/api-reference/studio-v1/experts.md - */ -import type { Fetcher } from "../fetcher.js" -import type { ApiResult, PaginatedResult, RequestOptions } from "../types.js" -import { - type CreateStudioExpertInput, - createStudioExpertResponseSchema, - deleteStudioExpertResponseSchema, - type ExpertDigest, - getStudioExpertResponseSchema, - type ListStudioExpertsParams, - listStudioExpertsResponseSchema, - type StudioExpert, - type UpdateStudioExpertInput, - updateStudioExpertResponseSchema, -} from "./types.js" - -export interface StudioExpertsApi { - get(key: string, options?: RequestOptions): Promise> - list( - params: ListStudioExpertsParams, - options?: RequestOptions, - ): Promise>> - create(input: CreateStudioExpertInput, options?: RequestOptions): Promise> - update( - key: string, - input: UpdateStudioExpertInput, - options?: RequestOptions, - ): Promise> - delete(key: string, options?: RequestOptions): Promise> -} - -export function createStudioExpertsApi(fetcher: Fetcher): StudioExpertsApi { - return { - async get(key, options) { - const result = await fetcher.get( - `/api/studio/v1/experts/${encodeURIComponent(key)}`, - getStudioExpertResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: result.data.data.expert } - }, - - async list(params, options) { - const searchParams = new URLSearchParams() - searchParams.set("applicationId", params.applicationId) - if (params.filter) searchParams.set("filter", params.filter) - if (params.sort) searchParams.set("sort", params.sort) - if (params.order) searchParams.set("order", params.order) - if (params.take !== undefined) searchParams.set("take", params.take.toString()) - if (params.skip !== undefined) searchParams.set("skip", params.skip.toString()) - - const path = `/api/studio/v1/experts/?${searchParams.toString()}` - - const result = await fetcher.get(path, listStudioExpertsResponseSchema, options) - if (!result.ok) return result - return { - ok: true, - data: { - data: result.data.data.experts, - meta: result.data.meta, - }, - } - }, - - async create(input, options) { - const result = await fetcher.post( - "/api/studio/v1/experts/", - input, - createStudioExpertResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: result.data.data.expert } - }, - - async update(key, input, options) { - const result = await fetcher.post( - `/api/studio/v1/experts/${encodeURIComponent(key)}`, - input, - updateStudioExpertResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: result.data.data.expert } - }, - - async delete(key, options) { - const result = await fetcher.delete( - `/api/studio/v1/experts/${encodeURIComponent(key)}`, - deleteStudioExpertResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: undefined } - }, - } -} diff --git a/packages/api-client/src/studio/types.test.ts b/packages/api-client/src/studio/types.test.ts deleted file mode 100644 index 885120c9..00000000 --- a/packages/api-client/src/studio/types.test.ts +++ /dev/null @@ -1,540 +0,0 @@ -import { describe, expect, it } from "vitest" -import { - applicationSchema, - checkpointSchema, - createCheckpointResponseSchema, - createStudioExpertResponseSchema, - createWorkspaceItemResponseSchema, - createWorkspaceSecretResponseSchema, - createWorkspaceVariableResponseSchema, - deleteStudioExpertResponseSchema, - deleteWorkspaceItemResponseSchema, - deleteWorkspaceSecretResponseSchema, - deleteWorkspaceVariableResponseSchema, - expertDigestSchema, - expertJobSchema, - expertJobStatusSchema, - findWorkspaceItemsResponseSchema, - getCheckpointResponseSchema, - getExpertJobResponseSchema, - getStudioExpertResponseSchema, - getWorkspaceInstanceResponseSchema, - getWorkspaceItemResponseSchema, - getWorkspaceResponseSchema, - listCheckpointsResponseSchema, - listExpertJobsResponseSchema, - listStudioExpertsResponseSchema, - listWorkspaceItemsResponseSchema, - ownerSchema, - startExpertJobResponseSchema, - studioExpertSchema, - updateExpertJobResponseSchema, - updateStudioExpertResponseSchema, - updateWorkspaceItemResponseSchema, - updateWorkspaceVariableResponseSchema, - workspaceInstanceSchema, - workspaceItemSchema, - workspaceSchema, - workspaceSecretSchema, - workspaceVariableSchema, -} from "./types.js" - -describe("applicationSchema", () => { - it("parses valid application", () => { - const app = applicationSchema.parse({ id: "app-123", name: "test-app" }) - expect(app.id).toBe("app-123") - expect(app.name).toBe("test-app") - }) -}) - -describe("ownerSchema", () => { - it("transforms createdAt to Date", () => { - const owner = ownerSchema.parse({ - organizationId: "org123", - createdAt: "2024-06-15T12:30:45Z", - }) - expect(owner.createdAt).toBeInstanceOf(Date) - }) -}) - -describe("studioExpertSchema", () => { - const validExpert = { - type: "studioExpert", - id: "test-id", - key: "my-expert", - name: "my-expert", - minRuntimeVersion: "v1.0", - description: "Test", - owner: { organizationId: "org123", createdAt: "2024-01-01T00:00:00Z" }, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - instruction: "Test", - skills: {}, - delegates: [], - application: { id: "app-123", name: "test-app" }, - } - - it("parses valid studio expert", () => { - const expert = studioExpertSchema.parse(validExpert) - expect(expert.type).toBe("studioExpert") - expect(expert.createdAt).toBeInstanceOf(Date) - expect(expert.updatedAt).toBeInstanceOf(Date) - }) - - it("parses expert with forkFrom", () => { - const expert = studioExpertSchema.parse({ ...validExpert, forkFrom: "base@1.0.0" }) - expect(expert.forkFrom).toBe("base@1.0.0") - }) -}) - -describe("expertDigestSchema", () => { - it("parses valid digest", () => { - const digest = expertDigestSchema.parse({ - type: "expertDigest", - id: "id-1", - key: "my-expert", - name: "my-expert", - minRuntimeVersion: "v1.0", - description: "Test", - owner: { organizationId: "org123", createdAt: "2024-01-01T00:00:00Z" }, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - tags: [], - }) - expect(digest.type).toBe("expertDigest") - }) -}) - -describe("expertJobStatusSchema", () => { - it("accepts valid status values", () => { - expect(expertJobStatusSchema.parse("queued")).toBe("queued") - expect(expertJobStatusSchema.parse("processing")).toBe("processing") - expect(expertJobStatusSchema.parse("completed")).toBe("completed") - expect(expertJobStatusSchema.parse("requestInteractiveToolResult")).toBe( - "requestInteractiveToolResult", - ) - expect(expertJobStatusSchema.parse("requestDelegateResult")).toBe("requestDelegateResult") - expect(expertJobStatusSchema.parse("exceededMaxSteps")).toBe("exceededMaxSteps") - expect(expertJobStatusSchema.parse("failed")).toBe("failed") - expect(expertJobStatusSchema.parse("canceling")).toBe("canceling") - expect(expertJobStatusSchema.parse("canceled")).toBe("canceled") - expect(expertJobStatusSchema.parse("expired")).toBe("expired") - }) -}) - -describe("expertJobSchema", () => { - it("parses valid job", () => { - const job = expertJobSchema.parse({ - id: "job-123", - expertKey: "my-expert", - status: "queued", - query: "Test query", - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - }) - expect(job.id).toBe("job-123") - expect(job.createdAt).toBeInstanceOf(Date) - }) - - it("parses job with optional dates", () => { - const job = expertJobSchema.parse({ - id: "job-123", - expertKey: "my-expert", - status: "completed", - query: "Test query", - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - startedAt: "2024-01-01T00:01:00Z", - completedAt: "2024-01-01T00:02:00Z", - }) - expect(job.startedAt).toBeInstanceOf(Date) - expect(job.completedAt).toBeInstanceOf(Date) - }) -}) - -describe("checkpointSchema", () => { - it("parses valid checkpoint", () => { - const checkpoint = checkpointSchema.parse({ - id: "cp-123", - expertJobId: "job-123", - sequence: 1, - createdAt: "2024-01-01T00:00:00Z", - }) - expect(checkpoint.id).toBe("cp-123") - expect(checkpoint.createdAt).toBeInstanceOf(Date) - }) -}) - -describe("workspaceInstanceSchema", () => { - it("parses valid workspace instance", () => { - const instance = workspaceInstanceSchema.parse({ - id: "wi-123", - expertJobId: "job-123", - createdAt: "2024-01-01T00:00:00Z", - }) - expect(instance.id).toBe("wi-123") - expect(instance.createdAt).toBeInstanceOf(Date) - }) -}) - -describe("workspaceItemSchema", () => { - it("parses valid file item", () => { - const item = workspaceItemSchema.parse({ - id: "item-123", - path: "/test/file.txt", - type: "file", - size: 1024, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - }) - expect(item.type).toBe("file") - expect(item.size).toBe(1024) - }) - - it("parses valid directory item", () => { - const item = workspaceItemSchema.parse({ - id: "item-123", - path: "/test/dir", - type: "directory", - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - }) - expect(item.type).toBe("directory") - expect(item.size).toBeUndefined() - }) -}) - -describe("workspaceSchema", () => { - it("parses valid workspace", () => { - const ws = workspaceSchema.parse({ - id: "ws-123", - applicationId: "app-123", - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - }) - expect(ws.id).toBe("ws-123") - expect(ws.createdAt).toBeInstanceOf(Date) - }) -}) - -describe("workspaceVariableSchema", () => { - it("parses valid variable", () => { - const variable = workspaceVariableSchema.parse({ name: "MY_VAR", value: "my-value" }) - expect(variable.name).toBe("MY_VAR") - expect(variable.value).toBe("my-value") - }) -}) - -describe("workspaceSecretSchema", () => { - it("parses valid secret", () => { - const secret = workspaceSecretSchema.parse({ name: "MY_SECRET" }) - expect(secret.name).toBe("MY_SECRET") - }) -}) - -describe("response schemas", () => { - describe("getStudioExpertResponseSchema", () => { - it("parses valid response", () => { - const response = getStudioExpertResponseSchema.parse({ - data: { - expert: { - type: "studioExpert", - id: "id-1", - key: "my-expert", - name: "my-expert", - minRuntimeVersion: "v1.0", - description: "Test", - owner: { organizationId: "org123", createdAt: "2024-01-01T00:00:00Z" }, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - instruction: "Test", - skills: {}, - delegates: [], - application: { id: "app-123", name: "test-app" }, - }, - }, - }) - expect(response.data.expert.name).toBe("my-expert") - }) - }) - - describe("listStudioExpertsResponseSchema", () => { - it("parses valid response", () => { - const response = listStudioExpertsResponseSchema.parse({ - data: { experts: [] }, - meta: { total: 0, take: 100, skip: 0 }, - }) - expect(response.data.experts).toEqual([]) - }) - }) - - describe("createStudioExpertResponseSchema", () => { - it("parses valid response", () => { - const response = createStudioExpertResponseSchema.parse({ - data: { - expert: { - type: "studioExpert", - id: "id-1", - key: "my-expert", - name: "my-expert", - minRuntimeVersion: "v1.0", - description: "Test", - owner: { organizationId: "org123", createdAt: "2024-01-01T00:00:00Z" }, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - instruction: "Test", - skills: {}, - delegates: [], - application: { id: "app-123", name: "test-app" }, - }, - }, - }) - expect(response.data.expert).toBeDefined() - }) - }) - - describe("updateStudioExpertResponseSchema", () => { - it("parses valid response", () => { - const response = updateStudioExpertResponseSchema.parse({ - data: { - expert: { - type: "studioExpert", - id: "id-1", - key: "my-expert", - name: "my-expert", - minRuntimeVersion: "v1.0", - description: "Test", - owner: { organizationId: "org123", createdAt: "2024-01-01T00:00:00Z" }, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - instruction: "Test", - skills: {}, - delegates: [], - application: { id: "app-123", name: "test-app" }, - }, - }, - }) - expect(response.data.expert).toBeDefined() - }) - }) - - describe("deleteStudioExpertResponseSchema", () => { - it("parses empty response", () => { - const response = deleteStudioExpertResponseSchema.parse({}) - expect(response).toEqual({}) - }) - }) - - describe("getExpertJobResponseSchema", () => { - it("parses valid response", () => { - const response = getExpertJobResponseSchema.parse({ - data: { - expertJob: { - id: "job-123", - expertKey: "my-expert", - status: "queued", - query: "Test", - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - }, - }, - }) - expect(response.data.expertJob.id).toBe("job-123") - }) - }) - - describe("listExpertJobsResponseSchema", () => { - it("parses valid response", () => { - const response = listExpertJobsResponseSchema.parse({ - data: { expertJobs: [] }, - meta: { total: 0, take: 100, skip: 0 }, - }) - expect(response.data.expertJobs).toEqual([]) - }) - }) - - describe("startExpertJobResponseSchema", () => { - it("parses valid response", () => { - const response = startExpertJobResponseSchema.parse({ - data: { - expertJob: { - id: "job-123", - expertKey: "my-expert", - status: "queued", - query: "Test", - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - }, - }, - }) - expect(response.data.expertJob).toBeDefined() - }) - }) - - describe("updateExpertJobResponseSchema", () => { - it("parses valid response", () => { - const response = updateExpertJobResponseSchema.parse({ - data: { - expertJob: { - id: "job-123", - expertKey: "my-expert", - status: "canceled", - query: "Test", - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - }, - }, - }) - expect(response.data.expertJob.status).toBe("canceled") - }) - }) - - describe("checkpoint schemas", () => { - it("getCheckpointResponseSchema parses valid response", () => { - const response = getCheckpointResponseSchema.parse({ - data: { - checkpoint: { - id: "cp-1", - expertJobId: "job-1", - sequence: 1, - createdAt: "2024-01-01T00:00:00Z", - }, - }, - }) - expect(response.data.checkpoint.id).toBe("cp-1") - }) - - it("listCheckpointsResponseSchema parses valid response", () => { - const response = listCheckpointsResponseSchema.parse({ - data: { checkpoints: [] }, - meta: { total: 0, take: 100, skip: 0 }, - }) - expect(response.data.checkpoints).toEqual([]) - }) - - it("createCheckpointResponseSchema parses valid response", () => { - const response = createCheckpointResponseSchema.parse({ - data: { - checkpoint: { - id: "cp-1", - expertJobId: "job-1", - sequence: 1, - createdAt: "2024-01-01T00:00:00Z", - }, - }, - }) - expect(response.data.checkpoint).toBeDefined() - }) - }) - - describe("workspace instance schemas", () => { - it("getWorkspaceInstanceResponseSchema parses valid response", () => { - const response = getWorkspaceInstanceResponseSchema.parse({ - data: { - workspaceInstance: { - id: "wi-1", - expertJobId: "job-1", - createdAt: "2024-01-01T00:00:00Z", - }, - }, - }) - expect(response.data.workspaceInstance.id).toBe("wi-1") - }) - }) - - describe("workspace item schemas", () => { - const mockItem = { - id: "item-1", - path: "/test.txt", - type: "file", - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - } - - it("getWorkspaceItemResponseSchema parses valid response", () => { - const response = getWorkspaceItemResponseSchema.parse({ data: { item: mockItem } }) - expect(response.data.item.id).toBe("item-1") - }) - - it("listWorkspaceItemsResponseSchema parses valid response", () => { - const response = listWorkspaceItemsResponseSchema.parse({ - data: { workspaceItems: [mockItem] }, - meta: { total: 1, take: 100, skip: 0 }, - }) - expect(response.data.workspaceItems).toHaveLength(1) - }) - - it("createWorkspaceItemResponseSchema parses valid response", () => { - const response = createWorkspaceItemResponseSchema.parse({ data: { item: mockItem } }) - expect(response.data.item).toBeDefined() - }) - - it("updateWorkspaceItemResponseSchema parses valid response", () => { - const response = updateWorkspaceItemResponseSchema.parse({ data: { item: mockItem } }) - expect(response.data.item).toBeDefined() - }) - - it("deleteWorkspaceItemResponseSchema parses empty response", () => { - const response = deleteWorkspaceItemResponseSchema.parse({}) - expect(response).toEqual({}) - }) - - it("findWorkspaceItemsResponseSchema parses valid response", () => { - const response = findWorkspaceItemsResponseSchema.parse({ - data: { workspaceItems: [mockItem] }, - }) - expect(response.data.workspaceItems).toHaveLength(1) - }) - }) - - describe("workspace schemas", () => { - it("getWorkspaceResponseSchema parses valid response", () => { - const response = getWorkspaceResponseSchema.parse({ - data: { - workspace: { - id: "ws-1", - applicationId: "app-1", - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - }, - }, - }) - expect(response.data.workspace.id).toBe("ws-1") - }) - }) - - describe("variable schemas", () => { - it("createWorkspaceVariableResponseSchema parses valid response", () => { - const response = createWorkspaceVariableResponseSchema.parse({ - data: { variable: { name: "MY_VAR", value: "val" } }, - }) - expect(response.data.variable.name).toBe("MY_VAR") - }) - - it("updateWorkspaceVariableResponseSchema parses valid response", () => { - const response = updateWorkspaceVariableResponseSchema.parse({ - data: { variable: { name: "MY_VAR", value: "new-val" } }, - }) - expect(response.data.variable.value).toBe("new-val") - }) - - it("deleteWorkspaceVariableResponseSchema parses empty response", () => { - const response = deleteWorkspaceVariableResponseSchema.parse({}) - expect(response).toEqual({}) - }) - }) - - describe("secret schemas", () => { - it("createWorkspaceSecretResponseSchema parses valid response", () => { - const response = createWorkspaceSecretResponseSchema.parse({ - data: { secret: { name: "MY_SECRET" } }, - }) - expect(response.data.secret.name).toBe("MY_SECRET") - }) - - it("deleteWorkspaceSecretResponseSchema parses empty response", () => { - const response = deleteWorkspaceSecretResponseSchema.parse({}) - expect(response).toEqual({}) - }) - }) -}) diff --git a/packages/api-client/src/studio/types.ts b/packages/api-client/src/studio/types.ts deleted file mode 100644 index 01a737b3..00000000 --- a/packages/api-client/src/studio/types.ts +++ /dev/null @@ -1,362 +0,0 @@ -import type { Skill } from "@perstack/core" -import { z } from "zod" - -export const applicationSchema = z.object({ - id: z.string(), - name: z.string(), -}) -export type Application = z.infer - -export const ownerSchema = z.object({ - name: z.string().optional(), - organizationId: z.string(), - createdAt: z.string().transform((s) => new Date(s)), -}) -export type Owner = z.infer - -export const studioExpertSchema = z.object({ - type: z.literal("studioExpert"), - id: z.string(), - key: z.string(), - name: z.string(), - minRuntimeVersion: z.literal("v1.0"), - description: z.string(), - owner: ownerSchema, - createdAt: z.string().transform((s) => new Date(s)), - updatedAt: z.string().transform((s) => new Date(s)), - instruction: z.string(), - skills: z.record(z.string(), z.unknown()).transform((s) => s as Record), - delegates: z.array(z.string()), - forkFrom: z.string().optional(), - application: applicationSchema, -}) -export type StudioExpert = z.infer - -export const expertDigestSchema = z.object({ - type: z.literal("expertDigest"), - id: z.string(), - key: z.string(), - name: z.string(), - minRuntimeVersion: z.literal("v1.0"), - description: z.string(), - owner: ownerSchema, - createdAt: z.string().transform((s) => new Date(s)), - updatedAt: z.string().transform((s) => new Date(s)), - version: z.string().optional(), - tags: z.array(z.string()), -}) -export type ExpertDigest = z.infer - -export const getStudioExpertResponseSchema = z.object({ - data: z.object({ - expert: studioExpertSchema, - }), -}) - -export const listStudioExpertsResponseSchema = z.object({ - data: z.object({ - experts: z.array(expertDigestSchema), - }), - meta: z.object({ - total: z.number(), - take: z.number(), - skip: z.number(), - }), -}) - -export const createStudioExpertResponseSchema = z.object({ - data: z.object({ - expert: studioExpertSchema, - }), -}) - -export const updateStudioExpertResponseSchema = z.object({ - data: z.object({ - expert: studioExpertSchema, - }), -}) - -export const deleteStudioExpertResponseSchema = z.object({}) - -export interface CreateStudioExpertInput { - name: string - minRuntimeVersion: "v1.0" - description: string - instruction: string - skills: Record - delegates: string[] - forkFrom?: string -} - -export interface UpdateStudioExpertInput { - minRuntimeVersion?: "v1.0" - description?: string - instruction?: string - skills?: Record - delegates?: string[] - forkFrom?: string -} - -export interface ListStudioExpertsParams { - applicationId: string - filter?: string - sort?: "name" | "version" | "createdAt" | "updatedAt" - order?: "asc" | "desc" - take?: number - skip?: number -} - -export const expertJobStatusSchema = z.enum([ - "queued", - "processing", - "completed", - "requestInteractiveToolResult", - "requestDelegateResult", - "exceededMaxSteps", - "failed", - "canceling", - "canceled", - "expired", -]) -export type ExpertJobStatus = z.infer - -export const expertJobSchema = z.object({ - id: z.string(), - expertKey: z.string(), - status: expertJobStatusSchema, - query: z.string().optional(), - createdAt: z.string().transform((s) => new Date(s)), - updatedAt: z.string().transform((s) => new Date(s)), - startedAt: z - .string() - .transform((s) => new Date(s)) - .optional(), - completedAt: z - .string() - .transform((s) => new Date(s)) - .optional(), -}) -export type ExpertJob = z.infer - -export const getExpertJobResponseSchema = z.object({ - data: z.object({ - expertJob: expertJobSchema, - }), -}) - -export const listExpertJobsResponseSchema = z.object({ - data: z.object({ - expertJobs: z.array(expertJobSchema), - }), - meta: z.object({ - total: z.number(), - take: z.number(), - skip: z.number(), - }), -}) - -export const startExpertJobResponseSchema = z.object({ - data: z.object({ - expertJob: expertJobSchema, - }), -}) - -export const continueExpertJobResponseSchema = z.object({ - data: z.object({ - expertJob: expertJobSchema, - }), -}) - -export const updateExpertJobResponseSchema = z.object({ - data: z.object({ - expertJob: expertJobSchema, - }), -}) - -export interface StartExpertJobInput { - expertKey: string - query: string - workspaceInstanceId?: string -} - -export interface ContinueExpertJobInput { - response: string -} - -export interface UpdateExpertJobInput { - status?: ExpertJobStatus -} - -export interface ListExpertJobsParams { - applicationId: string - filter?: string - sort?: "createdAt" | "updatedAt" | "status" - order?: "asc" | "desc" - take?: number - skip?: number -} - -export const checkpointSchema = z.object({ - id: z.string(), - expertJobId: z.string(), - sequence: z.number(), - createdAt: z.string().transform((s) => new Date(s)), -}) -export type Checkpoint = z.infer - -export const getCheckpointResponseSchema = z.object({ - data: z.object({ - checkpoint: checkpointSchema, - }), -}) - -export const listCheckpointsResponseSchema = z.object({ - data: z.object({ - checkpoints: z.array(checkpointSchema), - }), - meta: z.object({ - total: z.number(), - take: z.number(), - skip: z.number(), - }), -}) - -export const createCheckpointResponseSchema = z.object({ - data: z.object({ - checkpoint: checkpointSchema, - }), -}) - -export interface ListCheckpointsParams { - take?: number - skip?: number - sort?: "sequence" | "createdAt" - order?: "asc" | "desc" -} - -export const workspaceInstanceSchema = z.object({ - id: z.string(), - expertJobId: z.string(), - createdAt: z.string().transform((s) => new Date(s)), -}) -export type WorkspaceInstance = z.infer - -export const getWorkspaceInstanceResponseSchema = z.object({ - data: z.object({ - workspaceInstance: workspaceInstanceSchema, - }), -}) - -export const workspaceItemSchema = z.object({ - id: z.string(), - path: z.string(), - type: z.enum(["file", "directory"]), - size: z.number().optional(), - createdAt: z.string().transform((s) => new Date(s)), - updatedAt: z.string().transform((s) => new Date(s)), -}) -export type WorkspaceItem = z.infer - -export const getWorkspaceItemResponseSchema = z.object({ - data: z.object({ - item: workspaceItemSchema, - }), -}) - -export const listWorkspaceItemsResponseSchema = z.object({ - data: z.object({ - workspaceItems: z.array(workspaceItemSchema), - }), - meta: z.object({ - total: z.number(), - take: z.number(), - skip: z.number(), - }), -}) - -export const createWorkspaceItemResponseSchema = z.object({ - data: z.object({ - item: workspaceItemSchema, - }), -}) - -export const updateWorkspaceItemResponseSchema = z.object({ - data: z.object({ - item: workspaceItemSchema, - }), -}) - -export const deleteWorkspaceItemResponseSchema = z.object({}) - -export const findWorkspaceItemsResponseSchema = z.object({ - data: z.object({ - workspaceItems: z.array(workspaceItemSchema), - }), -}) - -export interface ListWorkspaceItemsParams { - take?: number - skip?: number - path?: string -} - -export const workspaceSchema = z.object({ - id: z.string(), - applicationId: z.string(), - createdAt: z.string().transform((s) => new Date(s)), - updatedAt: z.string().transform((s) => new Date(s)), -}) -export type Workspace = z.infer - -export const getWorkspaceResponseSchema = z.object({ - data: z.object({ - workspace: workspaceSchema, - }), -}) - -export const workspaceVariableSchema = z.object({ - name: z.string(), - value: z.string(), -}) -export type WorkspaceVariable = z.infer - -export const workspaceSecretSchema = z.object({ - name: z.string(), -}) -export type WorkspaceSecret = z.infer - -export const createWorkspaceVariableResponseSchema = z.object({ - data: z.object({ - variable: workspaceVariableSchema, - }), -}) - -export const updateWorkspaceVariableResponseSchema = z.object({ - data: z.object({ - variable: workspaceVariableSchema, - }), -}) - -export const deleteWorkspaceVariableResponseSchema = z.object({}) - -export const createWorkspaceSecretResponseSchema = z.object({ - data: z.object({ - secret: workspaceSecretSchema, - }), -}) - -export const deleteWorkspaceSecretResponseSchema = z.object({}) - -export interface CreateWorkspaceVariableInput { - name: string - value: string -} - -export interface UpdateWorkspaceVariableInput { - value: string -} - -export interface CreateWorkspaceSecretInput { - name: string - value: string -} diff --git a/packages/api-client/src/studio/workspace.test.ts b/packages/api-client/src/studio/workspace.test.ts deleted file mode 100644 index 443f8c63..00000000 --- a/packages/api-client/src/studio/workspace.test.ts +++ /dev/null @@ -1,432 +0,0 @@ -import { HttpResponse, http } from "msw" -import { setupServer } from "msw/node" -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest" -import { createFetcher } from "../fetcher.js" -import { createStudioWorkspaceApi } from "./workspace.js" - -const server = setupServer() - -beforeAll(() => server.listen({ onUnhandledRequest: "error" })) -afterEach(() => server.resetHandlers()) -afterAll(() => server.close()) - -const BASE_URL = "https://api.perstack.ai" - -const createMockWorkspace = (overrides = {}) => ({ - id: "ws-123", - applicationId: "app-123", - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - ...overrides, -}) - -const createMockWorkspaceItem = (overrides = {}) => ({ - id: "item-123", - path: "/test.txt", - type: "file" as const, - size: 1024, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - ...overrides, -}) - -const createMockVariable = (overrides = {}) => ({ - name: "MY_VAR", - value: "my-value", - ...overrides, -}) - -const createMockSecret = (overrides = {}) => ({ - name: "MY_SECRET", - ...overrides, -}) - -describe("createStudioWorkspaceApi", () => { - const fetcher = createFetcher() - const api = createStudioWorkspaceApi(fetcher) - - describe("get", () => { - it("returns workspace on success", async () => { - server.use( - http.get(`${BASE_URL}/api/studio/v1/workspace/`, () => { - return HttpResponse.json({ data: { workspace: createMockWorkspace() } }) - }), - ) - - const result = await api.get() - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.id).toBe("ws-123") - } - }) - - it("returns error on failure", async () => { - server.use( - http.get(`${BASE_URL}/api/studio/v1/workspace/`, () => { - return HttpResponse.json({ error: "Unauthorized" }, { status: 401 }) - }), - ) - - const result = await api.get() - expect(result.ok).toBe(false) - }) - }) - - describe("items", () => { - describe("get", () => { - it("returns item on success", async () => { - server.use( - http.get(`${BASE_URL}/api/studio/v1/workspace/items/:itemId`, () => { - return HttpResponse.json({ data: { item: createMockWorkspaceItem() } }) - }), - ) - - const result = await api.items.get("item-123") - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.id).toBe("item-123") - } - }) - - it("returns error on failure", async () => { - server.use( - http.get(`${BASE_URL}/api/studio/v1/workspace/items/:itemId`, () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }), - ) - - const result = await api.items.get("not-found") - expect(result.ok).toBe(false) - }) - }) - - describe("list", () => { - it("returns items on success", async () => { - server.use( - http.get(`${BASE_URL}/api/studio/v1/workspace/items/`, () => { - return HttpResponse.json({ - data: { workspaceItems: [createMockWorkspaceItem()] }, - meta: { total: 1, take: 100, skip: 0 }, - }) - }), - ) - - const result = await api.items.list() - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.data).toHaveLength(1) - } - }) - - it("includes query parameters", async () => { - let capturedUrl = "" - server.use( - http.get(`${BASE_URL}/api/studio/v1/workspace/items/`, ({ request }) => { - capturedUrl = request.url - return HttpResponse.json({ - data: { workspaceItems: [] }, - meta: { total: 0, take: 50, skip: 10 }, - }) - }), - ) - - await api.items.list({ take: 50, skip: 10, path: "/subdir" }) - - expect(capturedUrl).toContain("take=50") - expect(capturedUrl).toContain("skip=10") - expect(capturedUrl).toContain("path=%2Fsubdir") - }) - - it("returns error on failure", async () => { - server.use( - http.get(`${BASE_URL}/api/studio/v1/workspace/items/`, () => { - return HttpResponse.json({ error: "Unauthorized" }, { status: 401 }) - }), - ) - - const result = await api.items.list() - expect(result.ok).toBe(false) - }) - }) - - describe("create", () => { - it("creates item on success", async () => { - server.use( - http.post(`${BASE_URL}/api/studio/v1/workspace/items/`, () => { - return HttpResponse.json({ data: { item: createMockWorkspaceItem() } }) - }), - ) - - const blob = new Blob(["test content"]) - const result = await api.items.create("/test.txt", blob) - expect(result.ok).toBe(true) - }) - - it("returns error on failure", async () => { - server.use( - http.post(`${BASE_URL}/api/studio/v1/workspace/items/`, () => { - return HttpResponse.json({ error: "Bad Request" }, { status: 400 }) - }), - ) - - const blob = new Blob(["test"]) - const result = await api.items.create("/test.txt", blob) - expect(result.ok).toBe(false) - }) - }) - - describe("update", () => { - it("updates item on success", async () => { - server.use( - http.post(`${BASE_URL}/api/studio/v1/workspace/items/:itemId`, () => { - return HttpResponse.json({ data: { item: createMockWorkspaceItem() } }) - }), - ) - - const blob = new Blob(["updated content"]) - const result = await api.items.update("item-123", blob) - expect(result.ok).toBe(true) - }) - - it("returns error on failure", async () => { - server.use( - http.post(`${BASE_URL}/api/studio/v1/workspace/items/:itemId`, () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }), - ) - - const blob = new Blob(["test"]) - const result = await api.items.update("not-found", blob) - expect(result.ok).toBe(false) - }) - }) - - describe("delete", () => { - it("deletes item on success", async () => { - server.use( - http.delete(`${BASE_URL}/api/studio/v1/workspace/items/:itemId`, () => { - return HttpResponse.json({}) - }), - ) - - const result = await api.items.delete("item-123") - expect(result.ok).toBe(true) - }) - - it("returns error on failure", async () => { - server.use( - http.delete(`${BASE_URL}/api/studio/v1/workspace/items/:itemId`, () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }), - ) - - const result = await api.items.delete("not-found") - expect(result.ok).toBe(false) - }) - }) - - describe("download", () => { - it("downloads item on success", async () => { - const blobData = new Uint8Array([1, 2, 3, 4, 5]) - server.use( - http.get(`${BASE_URL}/api/studio/v1/workspace/items/:itemId/download`, () => { - return new HttpResponse(blobData, { - headers: { "Content-Type": "application/octet-stream" }, - }) - }), - ) - - const result = await api.items.download("item-123") - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toBeInstanceOf(Blob) - } - }) - - it("returns error on failure", async () => { - server.use( - http.get(`${BASE_URL}/api/studio/v1/workspace/items/:itemId/download`, () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }), - ) - - const result = await api.items.download("not-found") - expect(result.ok).toBe(false) - }) - }) - - describe("find", () => { - it("finds items on success", async () => { - server.use( - http.get(`${BASE_URL}/api/studio/v1/workspace/items/find`, () => { - return HttpResponse.json({ data: { workspaceItems: [createMockWorkspaceItem()] } }) - }), - ) - - const result = await api.items.find("*.txt") - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data).toHaveLength(1) - } - }) - - it("returns error on failure", async () => { - server.use( - http.get(`${BASE_URL}/api/studio/v1/workspace/items/find`, () => { - return HttpResponse.json({ error: "Unauthorized" }, { status: 401 }) - }), - ) - - const result = await api.items.find("*") - expect(result.ok).toBe(false) - }) - }) - }) - - describe("variables", () => { - describe("create", () => { - it("creates variable on success", async () => { - let capturedBody: unknown - server.use( - http.post(`${BASE_URL}/api/studio/v1/workspace/variables/`, async ({ request }) => { - capturedBody = await request.json() - return HttpResponse.json({ data: { variable: createMockVariable() } }) - }), - ) - - const result = await api.variables.create({ name: "MY_VAR", value: "my-value" }) - - expect(capturedBody).toEqual({ name: "MY_VAR", value: "my-value" }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.name).toBe("MY_VAR") - } - }) - - it("returns error on failure", async () => { - server.use( - http.post(`${BASE_URL}/api/studio/v1/workspace/variables/`, () => { - return HttpResponse.json({ error: "Bad Request" }, { status: 400 }) - }), - ) - - const result = await api.variables.create({ name: "MY_VAR", value: "val" }) - expect(result.ok).toBe(false) - }) - }) - - describe("update", () => { - it("updates variable on success", async () => { - let capturedBody: unknown - server.use( - http.post(`${BASE_URL}/api/studio/v1/workspace/variables/:name`, async ({ request }) => { - capturedBody = await request.json() - return HttpResponse.json({ - data: { variable: createMockVariable({ value: "new-value" }) }, - }) - }), - ) - - const result = await api.variables.update("MY_VAR", { value: "new-value" }) - - expect(capturedBody).toEqual({ value: "new-value" }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.value).toBe("new-value") - } - }) - - it("returns error on failure", async () => { - server.use( - http.post(`${BASE_URL}/api/studio/v1/workspace/variables/:name`, () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }), - ) - - const result = await api.variables.update("NOT_FOUND", { value: "val" }) - expect(result.ok).toBe(false) - }) - }) - - describe("delete", () => { - it("deletes variable on success", async () => { - server.use( - http.delete(`${BASE_URL}/api/studio/v1/workspace/variables/:name`, () => { - return HttpResponse.json({}) - }), - ) - - const result = await api.variables.delete("MY_VAR") - expect(result.ok).toBe(true) - }) - - it("returns error on failure", async () => { - server.use( - http.delete(`${BASE_URL}/api/studio/v1/workspace/variables/:name`, () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }), - ) - - const result = await api.variables.delete("NOT_FOUND") - expect(result.ok).toBe(false) - }) - }) - }) - - describe("secrets", () => { - describe("create", () => { - it("creates secret on success", async () => { - let capturedBody: unknown - server.use( - http.post(`${BASE_URL}/api/studio/v1/workspace/secrets/`, async ({ request }) => { - capturedBody = await request.json() - return HttpResponse.json({ data: { secret: createMockSecret() } }) - }), - ) - - const result = await api.secrets.create({ name: "MY_SECRET", value: "secret-value" }) - - expect(capturedBody).toEqual({ name: "MY_SECRET", value: "secret-value" }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.name).toBe("MY_SECRET") - } - }) - - it("returns error on failure", async () => { - server.use( - http.post(`${BASE_URL}/api/studio/v1/workspace/secrets/`, () => { - return HttpResponse.json({ error: "Bad Request" }, { status: 400 }) - }), - ) - - const result = await api.secrets.create({ name: "MY_SECRET", value: "val" }) - expect(result.ok).toBe(false) - }) - }) - - describe("delete", () => { - it("deletes secret on success", async () => { - server.use( - http.delete(`${BASE_URL}/api/studio/v1/workspace/secrets/:name`, () => { - return HttpResponse.json({}) - }), - ) - - const result = await api.secrets.delete("MY_SECRET") - expect(result.ok).toBe(true) - }) - - it("returns error on failure", async () => { - server.use( - http.delete(`${BASE_URL}/api/studio/v1/workspace/secrets/:name`, () => { - return HttpResponse.json({ error: "Not Found" }, { status: 404 }) - }), - ) - - const result = await api.secrets.delete("NOT_FOUND") - expect(result.ok).toBe(false) - }) - }) - }) -}) diff --git a/packages/api-client/src/studio/workspace.ts b/packages/api-client/src/studio/workspace.ts deleted file mode 100644 index 75b199d3..00000000 --- a/packages/api-client/src/studio/workspace.ts +++ /dev/null @@ -1,234 +0,0 @@ -/** - * Studio Workspace API - * - * @see docs/api-reference/studio-v1/workspace.md - */ -import type { Fetcher } from "../fetcher.js" -import type { ApiResult, PaginatedResult, RequestOptions } from "../types.js" -import { blobToBase64 } from "../utils.js" -import { - type CreateWorkspaceSecretInput, - type CreateWorkspaceVariableInput, - createWorkspaceItemResponseSchema, - createWorkspaceSecretResponseSchema, - createWorkspaceVariableResponseSchema, - deleteWorkspaceItemResponseSchema, - deleteWorkspaceSecretResponseSchema, - deleteWorkspaceVariableResponseSchema, - findWorkspaceItemsResponseSchema, - getWorkspaceItemResponseSchema, - getWorkspaceResponseSchema, - type ListWorkspaceItemsParams, - listWorkspaceItemsResponseSchema, - type UpdateWorkspaceVariableInput, - updateWorkspaceItemResponseSchema, - updateWorkspaceVariableResponseSchema, - type Workspace, - type WorkspaceItem, - type WorkspaceSecret, - type WorkspaceVariable, -} from "./types.js" - -export interface StudioWorkspaceApi { - get(options?: RequestOptions): Promise> - items: WorkspaceItemsApi - variables: WorkspaceVariablesApi - secrets: WorkspaceSecretsApi -} - -export interface WorkspaceItemsApi { - get(itemId: string, options?: RequestOptions): Promise> - list( - params?: ListWorkspaceItemsParams, - options?: RequestOptions, - ): Promise>> - create(path: string, content: Blob, options?: RequestOptions): Promise> - update(itemId: string, content: Blob, options?: RequestOptions): Promise> - delete(itemId: string, options?: RequestOptions): Promise> - download(itemId: string, options?: RequestOptions): Promise> - find(pattern: string, options?: RequestOptions): Promise> -} - -export interface WorkspaceVariablesApi { - create( - input: CreateWorkspaceVariableInput, - options?: RequestOptions, - ): Promise> - update( - name: string, - input: UpdateWorkspaceVariableInput, - options?: RequestOptions, - ): Promise> - delete(name: string, options?: RequestOptions): Promise> -} - -export interface WorkspaceSecretsApi { - create( - input: CreateWorkspaceSecretInput, - options?: RequestOptions, - ): Promise> - delete(name: string, options?: RequestOptions): Promise> -} - -export function createStudioWorkspaceApi(fetcher: Fetcher): StudioWorkspaceApi { - return { - async get(options) { - const result = await fetcher.get( - "/api/studio/v1/workspace/", - getWorkspaceResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: result.data.data.workspace } - }, - - items: createWorkspaceItemsApi(fetcher), - variables: createWorkspaceVariablesApi(fetcher), - secrets: createWorkspaceSecretsApi(fetcher), - } -} - -function createWorkspaceItemsApi(fetcher: Fetcher): WorkspaceItemsApi { - return { - async get(itemId, options) { - const result = await fetcher.get( - `/api/studio/v1/workspace/items/${encodeURIComponent(itemId)}`, - getWorkspaceItemResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: result.data.data.item } - }, - - async list(params, options) { - const searchParams = new URLSearchParams() - if (params?.take !== undefined) searchParams.set("take", params.take.toString()) - if (params?.skip !== undefined) searchParams.set("skip", params.skip.toString()) - if (params?.path) searchParams.set("path", params.path) - - const query = searchParams.toString() - const path = `/api/studio/v1/workspace/items/${query ? `?${query}` : ""}` - - const result = await fetcher.get(path, listWorkspaceItemsResponseSchema, options) - if (!result.ok) return result - return { - ok: true, - data: { - data: result.data.data.workspaceItems, - meta: result.data.meta, - }, - } - }, - - async create(itemPath, content, options) { - const result = await fetcher.post( - "/api/studio/v1/workspace/items/", - { path: itemPath, content: await blobToBase64(content) }, - createWorkspaceItemResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: result.data.data.item } - }, - - async update(itemId, content, options) { - const result = await fetcher.post( - `/api/studio/v1/workspace/items/${encodeURIComponent(itemId)}`, - { content: await blobToBase64(content) }, - updateWorkspaceItemResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: result.data.data.item } - }, - - async delete(itemId, options) { - const result = await fetcher.delete( - `/api/studio/v1/workspace/items/${encodeURIComponent(itemId)}`, - deleteWorkspaceItemResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: undefined } - }, - - async download(itemId, options) { - return fetcher.getBlob( - `/api/studio/v1/workspace/items/${encodeURIComponent(itemId)}/download`, - options, - ) - }, - - async find(pathPattern, options) { - const searchParams = new URLSearchParams() - searchParams.set("path", pathPattern) - - const path = `/api/studio/v1/workspace/items/find?${searchParams.toString()}` - - const result = await fetcher.get(path, findWorkspaceItemsResponseSchema, options) - if (!result.ok) return result - return { ok: true, data: result.data.data.workspaceItems } - }, - } -} - -function createWorkspaceVariablesApi(fetcher: Fetcher): WorkspaceVariablesApi { - return { - async create(input, options) { - const result = await fetcher.post( - "/api/studio/v1/workspace/variables/", - input, - createWorkspaceVariableResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: result.data.data.variable } - }, - - async update(name, input, options) { - const result = await fetcher.post( - `/api/studio/v1/workspace/variables/${encodeURIComponent(name)}`, - input, - updateWorkspaceVariableResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: result.data.data.variable } - }, - - async delete(name, options) { - const result = await fetcher.delete( - `/api/studio/v1/workspace/variables/${encodeURIComponent(name)}`, - deleteWorkspaceVariableResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: undefined } - }, - } -} - -function createWorkspaceSecretsApi(fetcher: Fetcher): WorkspaceSecretsApi { - return { - async create(input, options) { - const result = await fetcher.post( - "/api/studio/v1/workspace/secrets/", - input, - createWorkspaceSecretResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: result.data.data.secret } - }, - - async delete(name, options) { - const result = await fetcher.delete( - `/api/studio/v1/workspace/secrets/${encodeURIComponent(name)}`, - deleteWorkspaceSecretResponseSchema, - options, - ) - if (!result.ok) return result - return { ok: true, data: undefined } - }, - } -} diff --git a/packages/api-client/src/test-utils/mock-server.ts b/packages/api-client/src/test-utils/mock-server.ts deleted file mode 100644 index ebbceb18..00000000 --- a/packages/api-client/src/test-utils/mock-server.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { HttpResponse, http, type JsonBodyType } from "msw" - -const BASE_URL = "https://api.perstack.ai" - -export function overrideHandler( - method: "get" | "post" | "delete" | "put" | "patch", - urlPath: string, - response: T, - status = 200, -) { - const url = `${BASE_URL}${urlPath}` - return http[method](url, () => HttpResponse.json(response, { status })) -} - -export function overrideErrorHandler( - method: "get" | "post" | "delete" | "put" | "patch", - urlPath: string, - error: { code: number; error: string; reason?: string }, -) { - const url = `${BASE_URL}${urlPath}` - return http[method](url, () => HttpResponse.json(error, { status: error.code })) -} diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts deleted file mode 100644 index 15c79729..00000000 --- a/packages/api-client/src/types.ts +++ /dev/null @@ -1,35 +0,0 @@ -export type ApiResult = { ok: true; data: T } | { ok: false; error: ApiError } - -export interface ApiError { - code: number - message: string - reason?: unknown - aborted?: boolean -} - -export interface ApiClientConfig { - apiKey?: string - baseUrl?: string - timeout?: number -} - -export interface RequestOptions { - signal?: AbortSignal -} - -export interface PaginationParams { - take?: number - skip?: number - sort?: string - order?: "asc" | "desc" - filter?: string -} - -export interface PaginatedResult { - data: T[] - meta: { - total: number - take: number - skip: number - } -} diff --git a/packages/api-client/src/utils.ts b/packages/api-client/src/utils.ts deleted file mode 100644 index 7a7d1a1e..00000000 --- a/packages/api-client/src/utils.ts +++ /dev/null @@ -1,9 +0,0 @@ -export async function blobToBase64(blob: Blob): Promise { - const buffer = await blob.arrayBuffer() - const bytes = new Uint8Array(buffer) - let binary = "" - for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCharCode(bytes[i]) - } - return btoa(binary) -} diff --git a/packages/api-client/tsconfig.json b/packages/api-client/tsconfig.json deleted file mode 100644 index ed443cff..00000000 --- a/packages/api-client/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "@tsconfig/node22/tsconfig.json", - "include": ["**/*.ts", "**/*.tsx"], - "exclude": ["node_modules", "dist"] -} diff --git a/packages/api-client/tsup.config.ts b/packages/api-client/tsup.config.ts deleted file mode 100644 index f1f2df13..00000000 --- a/packages/api-client/tsup.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig, type Options } from "tsup" -import { baseConfig } from "../../tsup.config.js" - -export const apiClientConfig: Options = { - ...baseConfig, - entry: { - index: "src/index.ts", - }, -} - -export default defineConfig(apiClientConfig) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2177ceb2..24d3f4c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,8 +153,8 @@ importers: apps/perstack: dependencies: '@perstack/api-client': - specifier: workspace:* - version: link:../../packages/api-client + specifier: ^0.0.51 + version: 0.0.51(@perstack/core@packages+core)(zod@4.2.1) '@perstack/core': specifier: workspace:* version: link:../../packages/core @@ -241,8 +241,8 @@ importers: specifier: ^3.0.4 version: 3.0.4 '@perstack/api-client': - specifier: workspace:* - version: link:../../packages/api-client + specifier: ^0.0.51 + version: 0.0.51(@perstack/core@packages+core)(zod@4.2.1) '@perstack/base': specifier: workspace:* version: link:../base @@ -323,34 +323,6 @@ importers: specifier: ^4.0.16 version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@25.0.3)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) - packages/api-client: - dependencies: - '@perstack/core': - specifier: workspace:* - version: link:../core - zod: - specifier: ^4.2.1 - version: 4.2.1 - devDependencies: - '@tsconfig/node22': - specifier: ^22.0.5 - version: 22.0.5 - '@types/node': - specifier: ^25.0.3 - version: 25.0.3 - msw: - specifier: ^2.12.4 - version: 2.12.4(@types/node@25.0.3)(typescript@5.9.3) - tsup: - specifier: ^8.5.1 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) - typescript: - specifier: ^5.9.3 - version: 5.9.3 - vitest: - specifier: ^4.0.16 - version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@25.0.3)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) - packages/core: dependencies: '@paralleldrive/cuid2': @@ -1866,6 +1838,12 @@ packages: resolution: {integrity: sha512-sM6M2PWrByOEpN2QYAdulhEbSZmChwj0e52u4hpwB7u4PznFiNAavtE6m7O8tWUlzX+jT2eKKtc5/ZgX+IHrtg==} hasBin: true + '@perstack/api-client@0.0.51': + resolution: {integrity: sha512-MlZFsx3dOF8qblqPzEy87GgO+LcP6CHdy4uKcio415SLSSGLJVQpzmWc7EkXpKx+PjmQnSDNbtW9lbVHVFgiwA==} + peerDependencies: + '@perstack/core': '>=0.0.35' + zod: '>=4.0.0' + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -4987,7 +4965,8 @@ snapshots: dependencies: hono: 4.11.1 - '@inquirer/ansi@1.0.2': {} + '@inquirer/ansi@1.0.2': + optional: true '@inquirer/confirm@5.1.21(@types/node@25.0.3)': dependencies: @@ -4995,6 +4974,7 @@ snapshots: '@inquirer/type': 3.0.10(@types/node@25.0.3) optionalDependencies: '@types/node': 25.0.3 + optional: true '@inquirer/core@10.3.2(@types/node@25.0.3)': dependencies: @@ -5008,6 +4988,7 @@ snapshots: yoctocolors-cjs: 2.1.3 optionalDependencies: '@types/node': 25.0.3 + optional: true '@inquirer/external-editor@1.0.3(@types/node@25.0.3)': dependencies: @@ -5016,11 +4997,13 @@ snapshots: optionalDependencies: '@types/node': 25.0.3 - '@inquirer/figures@1.0.15': {} + '@inquirer/figures@1.0.15': + optional: true '@inquirer/type@3.0.10(@types/node@25.0.3)': optionalDependencies: '@types/node': 25.0.3 + optional: true '@isaacs/cliui@8.0.2': dependencies: @@ -5127,6 +5110,7 @@ snapshots: is-node-process: 1.2.0 outvariant: 1.4.3 strict-event-emitter: 0.5.1 + optional: true '@napi-rs/wasm-runtime@1.1.0': dependencies: @@ -5149,14 +5133,17 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 - '@open-draft/deferred-promise@2.2.0': {} + '@open-draft/deferred-promise@2.2.0': + optional: true '@open-draft/logger@0.3.0': dependencies: is-node-process: 1.2.0 outvariant: 1.4.3 + optional: true - '@open-draft/until@2.1.0': {} + '@open-draft/until@2.1.0': + optional: true '@opentelemetry/api@1.9.0': {} @@ -5228,6 +5215,11 @@ snapshots: bignumber.js: 9.3.1 error-causes: 3.0.2 + '@perstack/api-client@0.0.51(@perstack/core@packages+core)(zod@4.2.1)': + dependencies: + '@perstack/core': link:packages/core + zod: 4.2.1 + '@pkgjs/parseargs@0.11.0': optional: true @@ -5692,7 +5684,8 @@ snapshots: dependencies: csstype: 3.2.3 - '@types/statuses@2.0.6': {} + '@types/statuses@2.0.6': + optional: true '@vercel/oidc@3.0.5': {} @@ -5907,13 +5900,15 @@ snapshots: slice-ansi: 7.1.2 string-width: 8.1.0 - cli-width@4.1.0: {} + cli-width@4.1.0: + optional: true cliui@8.0.1: dependencies: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + optional: true code-excerpt@4.0.0: dependencies: @@ -5943,7 +5938,8 @@ snapshots: cookie@0.7.2: {} - cookie@1.1.1: {} + cookie@1.1.1: + optional: true cors@2.8.5: dependencies: @@ -6077,7 +6073,8 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 - escalade@3.2.0: {} + escalade@3.2.0: + optional: true escape-html@1.0.3: {} @@ -6250,7 +6247,8 @@ snapshots: transitivePeerDependencies: - supports-color - get-caller-file@2.0.5: {} + get-caller-file@2.0.5: + optional: true get-east-asian-width@1.4.0: {} @@ -6320,7 +6318,8 @@ snapshots: graceful-fs@4.2.11: {} - graphql@16.12.0: {} + graphql@16.12.0: + optional: true gtoken@8.0.0: dependencies: @@ -6337,7 +6336,8 @@ snapshots: dependencies: function-bind: 1.1.2 - headers-polyfill@4.0.3: {} + headers-polyfill@4.0.3: + optional: true hono@4.11.1: {} @@ -6433,7 +6433,8 @@ snapshots: is-in-ci@2.0.0: {} - is-node-process@1.2.0: {} + is-node-process@1.2.0: + optional: true is-number@7.0.0: {} @@ -6722,8 +6723,10 @@ snapshots: typescript: 5.9.3 transitivePeerDependencies: - '@types/node' + optional: true - mute-stream@2.0.0: {} + mute-stream@2.0.0: + optional: true mz@2.7.0: dependencies: @@ -6773,7 +6776,8 @@ snapshots: outdent@0.5.0: {} - outvariant@1.4.3: {} + outvariant@1.4.3: + optional: true oxc-resolver@11.16.0: optionalDependencies: @@ -6837,7 +6841,8 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 - path-to-regexp@6.3.0: {} + path-to-regexp@6.3.0: + optional: true path-to-regexp@8.3.0: {} @@ -6933,7 +6938,8 @@ snapshots: readdirp@4.1.2: {} - require-directory@2.1.1: {} + require-directory@2.1.1: + optional: true require-from-string@2.0.2: {} @@ -6946,7 +6952,8 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 - rettime@0.7.0: {} + rettime@0.7.0: + optional: true reusify@1.1.0: {} @@ -7105,7 +7112,8 @@ snapshots: std-env@3.10.0: {} - strict-event-emitter@0.5.1: {} + strict-event-emitter@0.5.1: + optional: true string-width@4.2.3: dependencies: @@ -7160,7 +7168,8 @@ snapshots: symbol-tree@3.2.4: {} - tagged-tag@1.0.0: {} + tagged-tag@1.0.0: + optional: true term-size@2.2.1: {} @@ -7290,6 +7299,7 @@ snapshots: type-fest@5.3.1: dependencies: tagged-tag: 1.0.0 + optional: true type-is@2.0.1: dependencies: @@ -7309,7 +7319,8 @@ snapshots: unpipe@1.0.0: {} - until-async@3.0.2: {} + until-async@3.0.2: + optional: true vary@1.1.2: {} @@ -7410,6 +7421,7 @@ snapshots: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 + optional: true wrap-ansi@7.0.0: dependencies: @@ -7439,12 +7451,14 @@ snapshots: xstate@5.25.0: {} - y18n@5.0.8: {} + y18n@5.0.8: + optional: true yaml@2.8.2: optional: true - yargs-parser@21.1.1: {} + yargs-parser@21.1.1: + optional: true yargs@17.7.2: dependencies: @@ -7455,8 +7469,10 @@ snapshots: string-width: 4.2.3 y18n: 5.0.8 yargs-parser: 21.1.1 + optional: true - yoctocolors-cjs@2.1.3: {} + yoctocolors-cjs@2.1.3: + optional: true yoga-layout@3.2.1: {}