From 178a7f3560396a93cb2aa0ec0902eb86d7a7040d Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Wed, 25 Feb 2026 09:21:36 +0000 Subject: [PATCH 1/7] feat: add studio expert management CLI commands - New @perstack/studio package with handlers for expert draft lifecycle - Add `perstack expert` command group with 10 subcommands: list, create, delete, push, refs, version, versions, publish, unpublish, yank - Add e2e test suite (e2e/studio/) for lifecycle and handler verification - Update CI workflow with studio e2e suite and PERSTACK_API_KEY secret - Bump @perstack/api-client to ^0.0.57 (expertDrafts in public client) Co-Authored-By: Claude Opus 4.6 --- .changeset/add-studio-expert-management.md | 12 ++ .github/workflows/e2e.yml | 6 + apps/perstack/bin/cli.ts | 124 +++++++++++++++ apps/perstack/package.json | 1 + bun.lock | 76 +++++---- e2e/studio/handlers.test.ts | 58 +++++++ e2e/studio/lifecycle.test.ts | 172 +++++++++++++++++++++ packages/installer/package.json | 2 +- packages/runtime/package.json | 2 +- packages/studio/package.json | 43 ++++++ packages/studio/src/client.ts | 24 +++ packages/studio/src/draft-handlers.ts | 138 +++++++++++++++++ packages/studio/src/index.ts | 26 ++++ packages/studio/src/publish-handlers.ts | 42 +++++ packages/studio/src/version-handlers.ts | 64 ++++++++ packages/studio/tsconfig.json | 5 + 16 files changed, 764 insertions(+), 31 deletions(-) create mode 100644 .changeset/add-studio-expert-management.md create mode 100644 e2e/studio/handlers.test.ts create mode 100644 e2e/studio/lifecycle.test.ts create mode 100644 packages/studio/package.json create mode 100644 packages/studio/src/client.ts create mode 100644 packages/studio/src/draft-handlers.ts create mode 100644 packages/studio/src/index.ts create mode 100644 packages/studio/src/publish-handlers.ts create mode 100644 packages/studio/src/version-handlers.ts create mode 100644 packages/studio/tsconfig.json diff --git a/.changeset/add-studio-expert-management.md b/.changeset/add-studio-expert-management.md new file mode 100644 index 00000000..dfce6123 --- /dev/null +++ b/.changeset/add-studio-expert-management.md @@ -0,0 +1,12 @@ +--- +"@perstack/studio": patch +"@perstack/installer": patch +"@perstack/runtime": patch +--- + +feat: add studio expert management commands to CLI + +- New `@perstack/studio` package with handlers for expert draft lifecycle (create, push, version, publish, unpublish, yank, delete) +- Add `perstack expert` command group with 10 subcommands +- Add e2e test suite for studio operations +- Bump `@perstack/api-client` to ^0.0.57 (expertDrafts in public client) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index c909e753..2291092e 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -47,6 +47,11 @@ jobs: - suite: cli-streaming # Streaming event tests (~50s) files: e2e/perstack-cli/streaming.test.ts + - suite: studio + # Studio expert lifecycle tests (~30s) + files: >- + e2e/studio/lifecycle.test.ts + e2e/studio/handlers.test.ts steps: - name: Checkout uses: actions/checkout@v6 @@ -74,6 +79,7 @@ jobs: EXA_API_KEY: ${{ secrets.EXA_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }} + PERSTACK_API_KEY: ${{ secrets.PERSTACK_API_KEY }} # Gate job for branch protection: required check that passes when E2E is # skipped (non-release PRs) or when all matrix suites succeed. diff --git a/apps/perstack/bin/cli.ts b/apps/perstack/bin/cli.ts index 6095f303..a6bc30ab 100755 --- a/apps/perstack/bin/cli.ts +++ b/apps/perstack/bin/cli.ts @@ -9,6 +9,18 @@ import { getPerstackConfig, loadLockfile, } from "@perstack/perstack-toml" +import { + expertCreateHandler, + expertDeleteHandler, + expertListHandler, + expertPublishHandler, + expertPushHandler, + expertRefsHandler, + expertUnpublishHandler, + expertVersionHandler, + expertVersionsHandler, + expertYankHandler, +} from "@perstack/studio" import { runHandler, startHandler } from "@perstack/tui" import { Command } from "commander" import packageJson from "../package.json" with { type: "json" } @@ -147,6 +159,118 @@ program await installHandler({ configPath, perstackConfig, envPath: options.envPath }) }) +// Expert management commands +function getParentOptions(cmd: InstanceType) { + const parent = cmd.parent?.opts() as { apiKey?: string; baseUrl?: string } | undefined + return { apiKey: parent?.apiKey, baseUrl: parent?.baseUrl } +} + +const expertCmd = program + .command("expert") + .description("Manage experts on Perstack API") + .option("--api-key ", "Perstack API key (default: PERSTACK_API_KEY env)") + .option("--base-url ", "Custom API base URL") + +expertCmd + .command("list") + .description("List draft scopes") + .option("--filter ", "Filter by name") + .option("--take ", "Limit results", Number.parseInt) + .option("--skip ", "Offset", Number.parseInt) + .action(async function (this: InstanceType, options) { + const parent = getParentOptions(this) + await expertListHandler({ ...parent, ...options }) + }) + +expertCmd + .command("create") + .description("Create a new draft scope") + .argument("", "Expert scope name") + .requiredOption("--app ", "Application ID") + .action(async function (this: InstanceType, scopeName, options) { + const parent = getParentOptions(this) + await expertCreateHandler(scopeName, { ...parent, ...options }) + }) + +expertCmd + .command("delete") + .description("Delete a draft scope") + .argument("", "Draft scope ID") + .action(async function (this: InstanceType, draftId) { + const parent = getParentOptions(this) + await expertDeleteHandler(draftId, parent) + }) + +expertCmd + .command("push") + .description("Push local expert definitions to a draft ref") + .argument("", "Draft scope ID") + .option("--config ", "Path to perstack.toml config file") + .action(async function (this: InstanceType, draftId, options) { + const parent = getParentOptions(this) + await expertPushHandler(draftId, { ...parent, ...options }) + }) + +expertCmd + .command("refs") + .description("List draft refs for a draft scope") + .argument("", "Draft scope ID") + .option("--take ", "Limit results", Number.parseInt) + .option("--skip ", "Offset", Number.parseInt) + .action(async function (this: InstanceType, draftId, options) { + const parent = getParentOptions(this) + await expertRefsHandler(draftId, { ...parent, ...options }) + }) + +expertCmd + .command("version") + .description("Assign a version to a draft ref") + .argument("", "Draft scope ID") + .argument("", "Draft ref ID") + .argument("", "Semantic version (e.g., 1.0.0)") + .option("--tag ", "Version tag (e.g., latest)") + .option("--readme ", "Path to README file") + .action(async function (this: InstanceType, draftId, refId, version, options) { + const parent = getParentOptions(this) + await expertVersionHandler(draftId, refId, version, { ...parent, ...options }) + }) + +expertCmd + .command("versions") + .description("List published versions for an expert scope") + .argument("", "Expert scope name") + .action(async function (this: InstanceType, scopeName) { + const parent = getParentOptions(this) + await expertVersionsHandler(scopeName, parent) + }) + +expertCmd + .command("publish") + .description("Make an expert scope public") + .argument("", "Expert scope name") + .action(async function (this: InstanceType, scopeName) { + const parent = getParentOptions(this) + await expertPublishHandler(scopeName, parent) + }) + +expertCmd + .command("unpublish") + .description("Make an expert scope private") + .argument("", "Expert scope name") + .action(async function (this: InstanceType, scopeName) { + const parent = getParentOptions(this) + await expertUnpublishHandler(scopeName, parent) + }) + +expertCmd + .command("yank") + .description("Deprecate a specific expert version") + .argument("", "Expert key with version (e.g., my-expert@1.0.0)") + .action(async function (this: InstanceType, key) { + const parent = getParentOptions(this) + await expertYankHandler(key, parent) + }) + program.parseAsync().catch((error) => { if (error instanceof PerstackError) { console.error(error.message) diff --git a/apps/perstack/package.json b/apps/perstack/package.json index 6cc8e8d6..97e1947e 100644 --- a/apps/perstack/package.json +++ b/apps/perstack/package.json @@ -27,6 +27,7 @@ "@perstack/installer": "workspace:*", "@perstack/log": "workspace:*", "@perstack/perstack-toml": "workspace:*", + "@perstack/studio": "workspace:*", "@perstack/tui": "workspace:*", "@tsconfig/node22": "^22.0.5", "@types/node": "^25.3.0", diff --git a/bun.lock b/bun.lock index b3ba2c4d..8c22183c 100644 --- a/bun.lock +++ b/bun.lock @@ -18,10 +18,10 @@ }, "apps/base": { "name": "@perstack/base", - "version": "0.0.68", + "version": "0.0.70", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", - "@perstack/core": "0.0.56", + "@perstack/core": "0.0.58", "commander": "^14.0.3", "zod": "^4.3.6", }, @@ -33,7 +33,7 @@ }, "apps/create-expert": { "name": "create-expert", - "version": "0.0.45", + "version": "0.0.46", "bin": { "create-expert": "bin/cli.ts", }, @@ -68,7 +68,7 @@ }, "apps/perstack": { "name": "perstack", - "version": "0.0.97", + "version": "0.0.98", "dependencies": { "commander": "^14.0.3", }, @@ -77,6 +77,7 @@ "@perstack/installer": "workspace:*", "@perstack/log": "workspace:*", "@perstack/perstack-toml": "workspace:*", + "@perstack/studio": "workspace:*", "@perstack/tui": "workspace:*", "@tsconfig/node22": "^22.0.5", "@types/node": "^25.3.0", @@ -85,7 +86,7 @@ }, "packages/core": { "name": "@perstack/core", - "version": "0.0.56", + "version": "0.0.58", "dependencies": { "@paralleldrive/cuid2": "^3.3.0", "zod": "^4.3.6", @@ -98,7 +99,7 @@ }, "packages/filesystem": { "name": "@perstack/filesystem-storage", - "version": "0.0.27", + "version": "0.0.29", "dependencies": { "@perstack/core": "workspace:*", }, @@ -111,9 +112,9 @@ }, "packages/installer": { "name": "@perstack/installer", - "version": "0.0.20", + "version": "0.0.22", "dependencies": { - "@perstack/api-client": "^0.0.56", + "@perstack/api-client": "^0.0.57", "@perstack/core": "workspace:*", "@perstack/perstack-toml": "workspace:*", "@perstack/skill-manager": "workspace:*", @@ -127,7 +128,7 @@ }, "packages/log": { "name": "@perstack/log", - "version": "0.0.13", + "version": "0.0.15", "dependencies": { "@perstack/core": "workspace:*", "@perstack/filesystem-storage": "workspace:*", @@ -140,7 +141,7 @@ }, "packages/perstack-toml": { "name": "@perstack/perstack-toml", - "version": "0.0.12", + "version": "0.0.14", "dependencies": { "@perstack/core": "workspace:*", "smol-toml": "^1.6.0", @@ -154,7 +155,7 @@ }, "packages/providers/anthropic": { "name": "@perstack/anthropic-provider", - "version": "0.0.29", + "version": "0.0.32", "dependencies": { "@ai-sdk/anthropic": "^3.0.47", "@perstack/core": "workspace:*", @@ -169,7 +170,7 @@ }, "packages/providers/azure-openai": { "name": "@perstack/azure-openai-provider", - "version": "0.0.29", + "version": "0.0.31", "dependencies": { "@ai-sdk/azure": "^3.0.31", "@perstack/core": "workspace:*", @@ -184,7 +185,7 @@ }, "packages/providers/bedrock": { "name": "@perstack/bedrock-provider", - "version": "0.0.29", + "version": "0.0.31", "dependencies": { "@ai-sdk/amazon-bedrock": "^4.0.60", "@perstack/core": "workspace:*", @@ -199,7 +200,7 @@ }, "packages/providers/core": { "name": "@perstack/provider-core", - "version": "0.0.29", + "version": "0.0.31", "dependencies": { "@perstack/core": "workspace:*", "undici": "^7.22.0", @@ -213,7 +214,7 @@ }, "packages/providers/deepseek": { "name": "@perstack/deepseek-provider", - "version": "0.0.29", + "version": "0.0.31", "dependencies": { "@ai-sdk/deepseek": "^2.0.20", "@perstack/core": "workspace:*", @@ -228,7 +229,7 @@ }, "packages/providers/google": { "name": "@perstack/google-provider", - "version": "0.0.29", + "version": "0.0.31", "dependencies": { "@ai-sdk/google": "^3.0.29", "@perstack/core": "workspace:*", @@ -243,7 +244,7 @@ }, "packages/providers/ollama": { "name": "@perstack/ollama-provider", - "version": "0.0.29", + "version": "0.0.31", "dependencies": { "@perstack/core": "workspace:*", "@perstack/provider-core": "workspace:*", @@ -258,7 +259,7 @@ }, "packages/providers/openai": { "name": "@perstack/openai-provider", - "version": "0.0.29", + "version": "0.0.31", "dependencies": { "@ai-sdk/openai": "^3.0.29", "@perstack/core": "workspace:*", @@ -273,7 +274,7 @@ }, "packages/providers/vertex": { "name": "@perstack/vertex-provider", - "version": "0.0.29", + "version": "0.0.31", "dependencies": { "@ai-sdk/google-vertex": "^4.0.58", "@perstack/core": "workspace:*", @@ -288,9 +289,9 @@ }, "packages/react": { "name": "@perstack/react", - "version": "0.0.60", + "version": "0.0.62", "dependencies": { - "@perstack/core": "workspace:*", + "@perstack/core": "0.0.58", }, "devDependencies": { "@testing-library/react": "^16.3.2", @@ -307,7 +308,7 @@ }, "packages/runtime": { "name": "@perstack/runtime", - "version": "0.0.117", + "version": "0.0.120", "dependencies": { "@ai-sdk/amazon-bedrock": "^4.0.60", "@ai-sdk/anthropic": "^3.0.44", @@ -318,9 +319,9 @@ "@ai-sdk/openai": "^3.0.29", "@modelcontextprotocol/sdk": "^1.26.0", "@paralleldrive/cuid2": "^3.3.0", - "@perstack/api-client": "^0.0.56", - "@perstack/base": "0.0.68", - "@perstack/core": "0.0.56", + "@perstack/api-client": "^0.0.57", + "@perstack/base": "0.0.70", + "@perstack/core": "0.0.58", "ai": "^6.0.86", "ollama-ai-provider-v2": "^3.3.0", "smol-toml": "^1.6.0", @@ -346,7 +347,7 @@ }, "packages/skill-manager": { "name": "@perstack/skill-manager", - "version": "0.0.14", + "version": "0.0.16", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", "@paralleldrive/cuid2": "^3.3.0", @@ -360,9 +361,24 @@ "typescript": "^5.9.3", }, }, + "packages/studio": { + "name": "@perstack/studio", + "version": "0.0.1", + "dependencies": { + "@perstack/api-client": "^0.0.57", + "@perstack/core": "workspace:*", + "@perstack/installer": "workspace:*", + "@perstack/perstack-toml": "workspace:*", + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.5", + "@types/node": "^25.3.0", + "typescript": "^5.9.3", + }, + }, "packages/tui": { "name": "@perstack/tui", - "version": "0.0.18", + "version": "0.0.20", "dependencies": { "@paralleldrive/cuid2": "^3.3.0", "@perstack/core": "workspace:*", @@ -379,7 +395,7 @@ }, "packages/tui-components": { "name": "@perstack/tui-components", - "version": "0.0.20", + "version": "0.0.22", "dependencies": { "@perstack/core": "workspace:*", "@perstack/react": "workspace:*", @@ -605,7 +621,7 @@ "@perstack/anthropic-provider": ["@perstack/anthropic-provider@workspace:packages/providers/anthropic"], - "@perstack/api-client": ["@perstack/api-client@0.0.56", "", { "peerDependencies": { "@perstack/core": ">0.0.42", "zod": ">=4.3.6" } }, "sha512-FL5xfx30HQHaCUU96eBbfMf7DGk3h+bAWoaaomvR65fz01Xe8+FZzGxLmdZy723yIFnFN3Dhp3JudYStxA5RxA=="], + "@perstack/api-client": ["@perstack/api-client@0.0.57", "", { "peerDependencies": { "@perstack/core": ">0.0.42", "zod": ">=4.3.6" } }, "sha512-SAVC3FiqdsntA2ITRcuGmoYBNYHtgGaKPMKzJXi9M6yUXw3g0OU5FqH69OgOFoKccC4bev+x5vWI1xSQQEpLSA=="], "@perstack/azure-openai-provider": ["@perstack/azure-openai-provider@workspace:packages/providers/azure-openai"], @@ -641,6 +657,8 @@ "@perstack/skill-manager": ["@perstack/skill-manager@workspace:packages/skill-manager"], + "@perstack/studio": ["@perstack/studio@workspace:packages/studio"], + "@perstack/tui": ["@perstack/tui@workspace:packages/tui"], "@perstack/tui-components": ["@perstack/tui-components@workspace:packages/tui-components"], diff --git a/e2e/studio/handlers.test.ts b/e2e/studio/handlers.test.ts new file mode 100644 index 00000000..b1117fdf --- /dev/null +++ b/e2e/studio/handlers.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test" +import { PerstackError } from "../../packages/core/src/index.ts" +import { expertListHandler, resolveApiKey } from "../../packages/studio/src/index.ts" + +const API_TIMEOUT = 30000 + +describe("Studio Handlers", () => { + describe("resolveApiKey", () => { + it("should return CLI key when provided", () => { + const key = resolveApiKey("test-key") + expect(key).toBe("test-key") + }) + + it("should return env key when no CLI key", () => { + const original = process.env.PERSTACK_API_KEY + process.env.PERSTACK_API_KEY = "env-key" + try { + const key = resolveApiKey() + expect(key).toBe("env-key") + } finally { + if (original) { + process.env.PERSTACK_API_KEY = original + } else { + delete process.env.PERSTACK_API_KEY + } + } + }) + + it("should throw PerstackError when no key available", () => { + const original = process.env.PERSTACK_API_KEY + delete process.env.PERSTACK_API_KEY + try { + expect(() => resolveApiKey()).toThrow(PerstackError) + } finally { + if (original) { + process.env.PERSTACK_API_KEY = original + } + } + }) + }) + + describe("expertListHandler", () => { + const apiKey = process.env.PERSTACK_API_KEY + if (!apiKey) { + it.skip("PERSTACK_API_KEY not set", () => {}) + return + } + + it( + "should list drafts without error", + async () => { + // Should complete without throwing + await expertListHandler({ apiKey }) + }, + API_TIMEOUT, + ) + }) +}) diff --git a/e2e/studio/lifecycle.test.ts b/e2e/studio/lifecycle.test.ts new file mode 100644 index 00000000..f321eecf --- /dev/null +++ b/e2e/studio/lifecycle.test.ts @@ -0,0 +1,172 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test" +import { createStudioClient } from "../../packages/studio/src/index.ts" + +const API_TIMEOUT = 30000 + +describe("Studio Lifecycle", () => { + const apiKey = process.env.PERSTACK_API_KEY + if (!apiKey) { + it.skip("PERSTACK_API_KEY not set", () => {}) + return + } + + const client = createStudioClient({ apiKey }) + const scopeName = `e2e-studio-test-${Date.now()}` + + let applicationId: string + let draftId: string + let refId: string + + beforeAll(async () => { + const appsResult = await client.applications.list() + expect(appsResult.ok).toBe(true) + if (!appsResult.ok) throw new Error("Failed to list applications") + const apps = appsResult.data.data.applications + expect(apps.length).toBeGreaterThan(0) + applicationId = apps[0].id + }) + + afterAll(async () => { + // Cleanup: attempt to delete the draft scope. + // This will fail with an FK constraint error if assignVersion created + // a linked expert_scope (no public API to delete expert_scopes yet). + if (draftId) { + await client.expertDrafts.delete(draftId).catch(() => {}) + } + }) + + it( + "should list drafts", + async () => { + const result = await client.expertDrafts.list() + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.data).toHaveProperty("data") + expect(result.data).toHaveProperty("meta") + expect(Array.isArray(result.data.data)).toBe(true) + }, + API_TIMEOUT, + ) + + it( + "should create a draft scope", + async () => { + const result = await client.expertDrafts.create({ + scopeName, + applicationId, + }) + expect(result.ok).toBe(true) + if (!result.ok) throw new Error(`Failed to create draft: ${result.error.message}`) + expect(result.data.data).toHaveProperty("id") + expect(result.data.data.name).toBe(scopeName) + draftId = result.data.data.id + }, + API_TIMEOUT, + ) + + it( + "should push experts (create ref)", + async () => { + expect(draftId).toBeDefined() + const experts = [ + { + key: scopeName, + name: scopeName, + version: "0.0.0-draft", + instruction: "E2E test expert — do nothing", + skills: { + "@perstack/base": { + type: "mcpStdioSkill" as const, + name: "@perstack/base", + description: "Base skill", + command: "npx" as const, + packageName: "@perstack/base", + pick: ["attemptCompletion"], + omit: [], + requiredEnv: [], + }, + }, + delegates: [], + tags: [], + }, + ] + const result = await client.expertDrafts.refs.create(draftId, { experts }) + expect(result.ok).toBe(true) + if (!result.ok) throw new Error(`Failed to push: ${result.error.message}`) + expect(result.data.data.draftRef).toHaveProperty("id") + refId = result.data.data.draftRef.id + }, + API_TIMEOUT, + ) + + it( + "should list refs", + async () => { + expect(draftId).toBeDefined() + const result = await client.expertDrafts.refs.list(draftId) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.data.data.length).toBeGreaterThan(0) + const found = result.data.data.some((ref) => ref.id === refId) + expect(found).toBe(true) + }, + API_TIMEOUT, + ) + + it( + "should assign version", + async () => { + expect(draftId).toBeDefined() + expect(refId).toBeDefined() + const result = await client.expertDrafts.refs.assignVersion(draftId, refId, { + version: "0.0.1-e2e-test", + }) + expect(result.ok).toBe(true) + if (!result.ok) throw new Error(`Failed to assign version: ${result.error.message}`) + expect(result.data.data.scope).toHaveProperty("name") + expect(result.data.data.version.version).toBe("0.0.1-e2e-test") + }, + API_TIMEOUT, + ) + + it( + "should list versions", + async () => { + const result = await client.experts.versions.list(scopeName) + expect(result.ok).toBe(true) + if (!result.ok) return + const versions = result.data.data.versions + expect(versions.length).toBeGreaterThan(0) + const found = versions.some((v) => v.version === "0.0.1-e2e-test") + expect(found).toBe(true) + }, + API_TIMEOUT, + ) + + it( + "should publish scope", + async () => { + const result = await client.experts.publish(scopeName) + expect(result.ok).toBe(true) + }, + API_TIMEOUT, + ) + + it( + "should unpublish scope", + async () => { + const result = await client.experts.unpublish(scopeName) + expect(result.ok).toBe(true) + }, + API_TIMEOUT, + ) + + it( + "should yank version", + async () => { + const result = await client.experts.yank(`${scopeName}@0.0.1-e2e-test`) + expect(result.ok).toBe(true) + }, + API_TIMEOUT, + ) +}) diff --git a/packages/installer/package.json b/packages/installer/package.json index bfcb60bb..187984b4 100644 --- a/packages/installer/package.json +++ b/packages/installer/package.json @@ -27,7 +27,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@perstack/api-client": "^0.0.56", + "@perstack/api-client": "^0.0.57", "@perstack/core": "workspace:*", "@perstack/perstack-toml": "workspace:*", "@perstack/skill-manager": "workspace:*" diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 45815873..c50a282b 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -36,7 +36,7 @@ "@ai-sdk/openai": "^3.0.29", "@modelcontextprotocol/sdk": "^1.26.0", "@paralleldrive/cuid2": "^3.3.0", - "@perstack/api-client": "^0.0.56", + "@perstack/api-client": "^0.0.57", "@perstack/base": "0.0.70", "@perstack/core": "0.0.58", "ai": "^6.0.86", diff --git a/packages/studio/package.json b/packages/studio/package.json new file mode 100644 index 00000000..7126146e --- /dev/null +++ b/packages/studio/package.json @@ -0,0 +1,43 @@ +{ + "private": true, + "version": "0.0.1", + "name": "@perstack/studio", + "description": "Perstack Studio - Expert management CLI handlers", + "author": "Wintermute Technologies, Inc.", + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js" + } + } + }, + "files": [ + "dist" + ], + "scripts": { + "clean": "rm -rf dist", + "build": "bun run clean && tsdown --config ../../tsdown.config.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@perstack/api-client": "^0.0.57", + "@perstack/core": "workspace:*", + "@perstack/installer": "workspace:*", + "@perstack/perstack-toml": "workspace:*" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.5", + "@types/node": "^25.3.0", + "typescript": "^5.9.3" + }, + "engines": { + "bun": ">=1.2.0" + } +} diff --git a/packages/studio/src/client.ts b/packages/studio/src/client.ts new file mode 100644 index 00000000..e3d4eb3b --- /dev/null +++ b/packages/studio/src/client.ts @@ -0,0 +1,24 @@ +import { createApiClient } from "@perstack/api-client" +import { PerstackError } from "@perstack/core" + +export interface StudioOptions { + apiKey: string + baseUrl?: string +} + +export function resolveApiKey(cliApiKey?: string): string { + const apiKey = cliApiKey ?? process.env.PERSTACK_API_KEY + if (!apiKey) { + throw new PerstackError( + "PERSTACK_API_KEY is required. Set it as an environment variable or pass --api-key.", + ) + } + return apiKey +} + +export function createStudioClient(options: StudioOptions) { + return createApiClient({ + apiKey: options.apiKey, + baseUrl: options.baseUrl, + }) +} diff --git a/packages/studio/src/draft-handlers.ts b/packages/studio/src/draft-handlers.ts new file mode 100644 index 00000000..c39f83e6 --- /dev/null +++ b/packages/studio/src/draft-handlers.ts @@ -0,0 +1,138 @@ +import { PerstackError } from "@perstack/core" +import { configExpertToExpert } from "@perstack/installer" +import { getPerstackConfig } from "@perstack/perstack-toml" +import { createStudioClient, resolveApiKey } from "./client.js" + +export interface ExpertListOptions { + apiKey?: string + baseUrl?: string + filter?: string + take?: number + skip?: number +} + +export async function expertListHandler(options: ExpertListOptions) { + const apiKey = resolveApiKey(options.apiKey) + const client = createStudioClient({ apiKey, baseUrl: options.baseUrl }) + const result = await client.expertDrafts.list({ + filter: options.filter, + take: options.take, + skip: options.skip, + }) + if (!result.ok) { + throw new PerstackError(`Failed to list drafts: ${result.error.message}`) + } + const { data, meta } = result.data + if (data.length === 0) { + console.log("No draft scopes found.") + return + } + console.log(`Draft scopes (${meta.total} total):\n`) + for (const draft of data) { + const version = draft.currentVersion?.version ?? "-" + const refsCount = draft.draftRefs?.length ?? 0 + console.log(` ${draft.name}`) + console.log(` ID: ${draft.id}`) + console.log(` Refs: ${refsCount} Version: ${version}`) + console.log() + } +} + +export interface ExpertCreateOptions { + apiKey?: string + baseUrl?: string + app: string +} + +export async function expertCreateHandler(scopeName: string, options: ExpertCreateOptions) { + const apiKey = resolveApiKey(options.apiKey) + const client = createStudioClient({ apiKey, baseUrl: options.baseUrl }) + const result = await client.expertDrafts.create({ + scopeName, + applicationId: options.app, + }) + if (!result.ok) { + throw new PerstackError(`Failed to create draft: ${result.error.message}`) + } + const draft = result.data.data + console.log(`Draft scope created:`) + console.log(` Name: ${draft.name}`) + console.log(` ID: ${draft.id}`) +} + +export interface ExpertDeleteOptions { + apiKey?: string + baseUrl?: string +} + +export async function expertDeleteHandler(draftId: string, options: ExpertDeleteOptions) { + const apiKey = resolveApiKey(options.apiKey) + const client = createStudioClient({ apiKey, baseUrl: options.baseUrl }) + const result = await client.expertDrafts.delete(draftId) + if (!result.ok) { + throw new PerstackError(`Failed to delete draft: ${result.error.message}`) + } + console.log(`Draft scope deleted: ${draftId}`) +} + +export interface ExpertPushOptions { + apiKey?: string + baseUrl?: string + config?: string +} + +export async function expertPushHandler(draftId: string, options: ExpertPushOptions) { + const apiKey = resolveApiKey(options.apiKey) + const client = createStudioClient({ apiKey, baseUrl: options.baseUrl }) + const perstackConfig = await getPerstackConfig(options.config) + if (!perstackConfig.experts || Object.keys(perstackConfig.experts).length === 0) { + throw new PerstackError("No experts defined in perstack.toml") + } + const experts = Object.entries(perstackConfig.experts).map(([key, configExpert]) => + configExpertToExpert(key, configExpert), + ) + // Expert type from @perstack/core has slightly looser types than the API expects + // (e.g., skill.description is optional in core but required in API) + // The runtime values are compatible since expertSchema enforces defaults + const result = await client.expertDrafts.refs.create(draftId, { + experts: experts as Parameters[1]["experts"], + }) + if (!result.ok) { + throw new PerstackError(`Failed to push experts: ${result.error.message}`) + } + const { draftRef, definition } = result.data.data + const expertKeys = Object.keys(definition.experts) + console.log(`Draft ref created:`) + console.log(` Ref ID: ${draftRef.id}`) + console.log(` Experts: ${expertKeys.join(", ")}`) +} + +export interface ExpertRefsOptions { + apiKey?: string + baseUrl?: string + take?: number + skip?: number +} + +export async function expertRefsHandler(draftId: string, options: ExpertRefsOptions) { + const apiKey = resolveApiKey(options.apiKey) + const client = createStudioClient({ apiKey, baseUrl: options.baseUrl }) + const result = await client.expertDrafts.refs.list(draftId, { + take: options.take, + skip: options.skip, + }) + if (!result.ok) { + throw new PerstackError(`Failed to list refs: ${result.error.message}`) + } + const { data, meta } = result.data + if (data.length === 0) { + console.log("No draft refs found.") + return + } + console.log(`Draft refs (${meta.total} total):\n`) + for (const ref of data) { + console.log(` ${ref.id}`) + console.log(` Created: ${ref.createdAt}`) + console.log() + } +} diff --git a/packages/studio/src/index.ts b/packages/studio/src/index.ts new file mode 100644 index 00000000..03342cd4 --- /dev/null +++ b/packages/studio/src/index.ts @@ -0,0 +1,26 @@ +export { createStudioClient, resolveApiKey, type StudioOptions } from "./client.js" +export { + type ExpertCreateOptions, + type ExpertDeleteOptions, + type ExpertListOptions, + type ExpertPushOptions, + type ExpertRefsOptions, + expertCreateHandler, + expertDeleteHandler, + expertListHandler, + expertPushHandler, + expertRefsHandler, +} from "./draft-handlers.js" +export { + type ExpertPublishOptions, + type ExpertYankOptions, + expertPublishHandler, + expertUnpublishHandler, + expertYankHandler, +} from "./publish-handlers.js" +export { + type ExpertVersionOptions, + type ExpertVersionsOptions, + expertVersionHandler, + expertVersionsHandler, +} from "./version-handlers.js" diff --git a/packages/studio/src/publish-handlers.ts b/packages/studio/src/publish-handlers.ts new file mode 100644 index 00000000..48309cad --- /dev/null +++ b/packages/studio/src/publish-handlers.ts @@ -0,0 +1,42 @@ +import { PerstackError } from "@perstack/core" +import { createStudioClient, resolveApiKey } from "./client.js" + +export interface ExpertPublishOptions { + apiKey?: string + baseUrl?: string +} + +export async function expertPublishHandler(scopeName: string, options: ExpertPublishOptions) { + const apiKey = resolveApiKey(options.apiKey) + const client = createStudioClient({ apiKey, baseUrl: options.baseUrl }) + const result = await client.experts.publish(scopeName) + if (!result.ok) { + throw new PerstackError(`Failed to publish: ${result.error.message}`) + } + console.log(`Expert scope published: ${scopeName}`) +} + +export async function expertUnpublishHandler(scopeName: string, options: ExpertPublishOptions) { + const apiKey = resolveApiKey(options.apiKey) + const client = createStudioClient({ apiKey, baseUrl: options.baseUrl }) + const result = await client.experts.unpublish(scopeName) + if (!result.ok) { + throw new PerstackError(`Failed to unpublish: ${result.error.message}`) + } + console.log(`Expert scope unpublished: ${scopeName}`) +} + +export interface ExpertYankOptions { + apiKey?: string + baseUrl?: string +} + +export async function expertYankHandler(key: string, options: ExpertYankOptions) { + const apiKey = resolveApiKey(options.apiKey) + const client = createStudioClient({ apiKey, baseUrl: options.baseUrl }) + const result = await client.experts.yank(key) + if (!result.ok) { + throw new PerstackError(`Failed to yank: ${result.error.message}`) + } + console.log(`Expert version yanked: ${key}`) +} diff --git a/packages/studio/src/version-handlers.ts b/packages/studio/src/version-handlers.ts new file mode 100644 index 00000000..598d1115 --- /dev/null +++ b/packages/studio/src/version-handlers.ts @@ -0,0 +1,64 @@ +import { readFile } from "node:fs/promises" +import { PerstackError } from "@perstack/core" +import { createStudioClient, resolveApiKey } from "./client.js" + +export interface ExpertVersionOptions { + apiKey?: string + baseUrl?: string + tag?: string + readme?: string +} + +export async function expertVersionHandler( + draftId: string, + refId: string, + version: string, + options: ExpertVersionOptions, +) { + const apiKey = resolveApiKey(options.apiKey) + const client = createStudioClient({ apiKey, baseUrl: options.baseUrl }) + let readme: string | undefined + if (options.readme) { + readme = await readFile(options.readme, "utf-8") + } + const result = await client.expertDrafts.refs.assignVersion(draftId, refId, { + version, + tag: options.tag, + readme, + }) + if (!result.ok) { + throw new PerstackError(`Failed to assign version: ${result.error.message}`) + } + const { scope, version: ver } = result.data.data + console.log(`Version assigned:`) + console.log(` Scope: ${scope.name}`) + console.log(` Version: ${ver.version}`) + console.log(` Tags: ${ver.tags.length > 0 ? ver.tags.join(", ") : "-"}`) +} + +export interface ExpertVersionsOptions { + apiKey?: string + baseUrl?: string +} + +export async function expertVersionsHandler(scopeName: string, options: ExpertVersionsOptions) { + const apiKey = resolveApiKey(options.apiKey) + const client = createStudioClient({ apiKey, baseUrl: options.baseUrl }) + const result = await client.experts.versions.list(scopeName) + if (!result.ok) { + throw new PerstackError(`Failed to list versions: ${result.error.message}`) + } + const { versions } = result.data.data + if (versions.length === 0) { + console.log("No published versions found.") + return + } + console.log(`Versions for ${scopeName}:\n`) + for (const ver of versions) { + const tags = ver.tags.length > 0 ? ` [${ver.tags.join(", ")}]` : "" + const yanked = ver.yanked ? " (yanked)" : "" + console.log(` ${ver.version}${tags}${yanked}`) + console.log(` Created: ${ver.createdAt}`) + console.log() + } +} diff --git a/packages/studio/tsconfig.json b/packages/studio/tsconfig.json new file mode 100644 index 00000000..c33bc685 --- /dev/null +++ b/packages/studio/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@tsconfig/node22/tsconfig.json", + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} From 2ea2b6ca9ff2ad37fccbeca884e8cdf15f84ccb0 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Wed, 25 Feb 2026 09:30:16 +0000 Subject: [PATCH 2/7] fix: make expert versions command work without API key The experts.versions.list endpoint uses MaybeAuthenticate, so API key is not required for public experts. Remove the mandatory resolveApiKey call from expertVersionsHandler and make StudioOptions.apiKey optional. Co-Authored-By: Claude Opus 4.6 --- e2e/studio/handlers.test.ts | 27 ++++++++++++++++++++++++- packages/studio/src/client.ts | 2 +- packages/studio/src/version-handlers.ts | 2 +- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/e2e/studio/handlers.test.ts b/e2e/studio/handlers.test.ts index b1117fdf..c94f846d 100644 --- a/e2e/studio/handlers.test.ts +++ b/e2e/studio/handlers.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "bun:test" import { PerstackError } from "../../packages/core/src/index.ts" -import { expertListHandler, resolveApiKey } from "../../packages/studio/src/index.ts" +import { + expertListHandler, + expertVersionsHandler, + resolveApiKey, +} from "../../packages/studio/src/index.ts" const API_TIMEOUT = 30000 @@ -39,6 +43,27 @@ describe("Studio Handlers", () => { }) }) + describe("expertVersionsHandler", () => { + it( + "should not require API key for public endpoint", + async () => { + const original = process.env.PERSTACK_API_KEY + delete process.env.PERSTACK_API_KEY + try { + // Should throw PerstackError about API failure (404), NOT about missing API key + await expect(expertVersionsHandler("nonexistent-scope", {})).rejects.toThrow( + "Failed to list versions", + ) + } finally { + if (original) { + process.env.PERSTACK_API_KEY = original + } + } + }, + API_TIMEOUT, + ) + }) + describe("expertListHandler", () => { const apiKey = process.env.PERSTACK_API_KEY if (!apiKey) { diff --git a/packages/studio/src/client.ts b/packages/studio/src/client.ts index e3d4eb3b..5a69ebae 100644 --- a/packages/studio/src/client.ts +++ b/packages/studio/src/client.ts @@ -2,7 +2,7 @@ import { createApiClient } from "@perstack/api-client" import { PerstackError } from "@perstack/core" export interface StudioOptions { - apiKey: string + apiKey?: string baseUrl?: string } diff --git a/packages/studio/src/version-handlers.ts b/packages/studio/src/version-handlers.ts index 598d1115..70e6ae1d 100644 --- a/packages/studio/src/version-handlers.ts +++ b/packages/studio/src/version-handlers.ts @@ -42,7 +42,7 @@ export interface ExpertVersionsOptions { } export async function expertVersionsHandler(scopeName: string, options: ExpertVersionsOptions) { - const apiKey = resolveApiKey(options.apiKey) + const apiKey = options.apiKey ?? process.env.PERSTACK_API_KEY const client = createStudioClient({ apiKey, baseUrl: options.baseUrl }) const result = await client.experts.versions.list(scopeName) if (!result.ok) { From 0fa4120b2212a5c98331ab1fc42d6b6d16bf9cb2 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Wed, 25 Feb 2026 09:32:33 +0000 Subject: [PATCH 3/7] test: add with/without API key cases for expertVersionsHandler Co-Authored-By: Claude Opus 4.6 --- e2e/studio/handlers.test.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/e2e/studio/handlers.test.ts b/e2e/studio/handlers.test.ts index c94f846d..e9a34fa2 100644 --- a/e2e/studio/handlers.test.ts +++ b/e2e/studio/handlers.test.ts @@ -45,12 +45,12 @@ describe("Studio Handlers", () => { describe("expertVersionsHandler", () => { it( - "should not require API key for public endpoint", + "should work without API key (public endpoint)", async () => { const original = process.env.PERSTACK_API_KEY delete process.env.PERSTACK_API_KEY try { - // Should throw PerstackError about API failure (404), NOT about missing API key + // Should reach the API and get a 404, NOT throw "PERSTACK_API_KEY is required" await expect(expertVersionsHandler("nonexistent-scope", {})).rejects.toThrow( "Failed to list versions", ) @@ -62,6 +62,22 @@ describe("Studio Handlers", () => { }, API_TIMEOUT, ) + + it( + "should work with API key", + async () => { + const apiKey = process.env.PERSTACK_API_KEY + if (!apiKey) { + console.log("PERSTACK_API_KEY not set, skipping") + return + } + // Should reach the API and get a 404, not an auth error + await expect(expertVersionsHandler("nonexistent-scope", { apiKey })).rejects.toThrow( + "Failed to list versions", + ) + }, + API_TIMEOUT, + ) }) describe("expertListHandler", () => { From b42929513183a6def39524374bf197e2ce1f55ba Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Wed, 25 Feb 2026 09:36:45 +0000 Subject: [PATCH 4/7] feat: point studio e2e tests at staging API Versioned expert scopes cannot be deleted, so e2e tests should target staging (stg-api.perstack.ai) instead of production to avoid polluting prod data. Uses PERSTACK_API_BASE_URL env var with staging as default. Co-Authored-By: Claude Opus 4.6 --- e2e/studio/handlers.test.ts | 15 ++++++++------- e2e/studio/lifecycle.test.ts | 3 ++- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/e2e/studio/handlers.test.ts b/e2e/studio/handlers.test.ts index e9a34fa2..b55a53a1 100644 --- a/e2e/studio/handlers.test.ts +++ b/e2e/studio/handlers.test.ts @@ -7,6 +7,7 @@ import { } from "../../packages/studio/src/index.ts" const API_TIMEOUT = 30000 +const BASE_URL = process.env.PERSTACK_API_BASE_URL ?? "https://stg-api.perstack.ai" describe("Studio Handlers", () => { describe("resolveApiKey", () => { @@ -51,9 +52,9 @@ describe("Studio Handlers", () => { delete process.env.PERSTACK_API_KEY try { // Should reach the API and get a 404, NOT throw "PERSTACK_API_KEY is required" - await expect(expertVersionsHandler("nonexistent-scope", {})).rejects.toThrow( - "Failed to list versions", - ) + await expect( + expertVersionsHandler("nonexistent-scope", { baseUrl: BASE_URL }), + ).rejects.toThrow("Failed to list versions") } finally { if (original) { process.env.PERSTACK_API_KEY = original @@ -72,9 +73,9 @@ describe("Studio Handlers", () => { return } // Should reach the API and get a 404, not an auth error - await expect(expertVersionsHandler("nonexistent-scope", { apiKey })).rejects.toThrow( - "Failed to list versions", - ) + await expect( + expertVersionsHandler("nonexistent-scope", { apiKey, baseUrl: BASE_URL }), + ).rejects.toThrow("Failed to list versions") }, API_TIMEOUT, ) @@ -91,7 +92,7 @@ describe("Studio Handlers", () => { "should list drafts without error", async () => { // Should complete without throwing - await expertListHandler({ apiKey }) + await expertListHandler({ apiKey, baseUrl: BASE_URL }) }, API_TIMEOUT, ) diff --git a/e2e/studio/lifecycle.test.ts b/e2e/studio/lifecycle.test.ts index f321eecf..580e40f0 100644 --- a/e2e/studio/lifecycle.test.ts +++ b/e2e/studio/lifecycle.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test" import { createStudioClient } from "../../packages/studio/src/index.ts" const API_TIMEOUT = 30000 +const BASE_URL = process.env.PERSTACK_API_BASE_URL ?? "https://stg-api.perstack.ai" describe("Studio Lifecycle", () => { const apiKey = process.env.PERSTACK_API_KEY @@ -10,7 +11,7 @@ describe("Studio Lifecycle", () => { return } - const client = createStudioClient({ apiKey }) + const client = createStudioClient({ apiKey, baseUrl: BASE_URL }) const scopeName = `e2e-studio-test-${Date.now()}` let applicationId: string From 8c688aa606faf763019c6416420b32be9b256e51 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Wed, 25 Feb 2026 09:38:20 +0000 Subject: [PATCH 5/7] refactor: hardcode staging base URL in studio e2e tests Co-Authored-By: Claude Opus 4.6 --- e2e/studio/handlers.test.ts | 2 +- e2e/studio/lifecycle.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/studio/handlers.test.ts b/e2e/studio/handlers.test.ts index b55a53a1..3503731b 100644 --- a/e2e/studio/handlers.test.ts +++ b/e2e/studio/handlers.test.ts @@ -7,7 +7,7 @@ import { } from "../../packages/studio/src/index.ts" const API_TIMEOUT = 30000 -const BASE_URL = process.env.PERSTACK_API_BASE_URL ?? "https://stg-api.perstack.ai" +const BASE_URL = "https://stg-api.perstack.ai" describe("Studio Handlers", () => { describe("resolveApiKey", () => { diff --git a/e2e/studio/lifecycle.test.ts b/e2e/studio/lifecycle.test.ts index 580e40f0..f17d695d 100644 --- a/e2e/studio/lifecycle.test.ts +++ b/e2e/studio/lifecycle.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test" import { createStudioClient } from "../../packages/studio/src/index.ts" const API_TIMEOUT = 30000 -const BASE_URL = process.env.PERSTACK_API_BASE_URL ?? "https://stg-api.perstack.ai" +const BASE_URL = "https://stg-api.perstack.ai" describe("Studio Lifecycle", () => { const apiKey = process.env.PERSTACK_API_KEY From 3827c215f5580d2ea232ce2509dcf147511220c3 Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Wed, 25 Feb 2026 09:39:14 +0000 Subject: [PATCH 6/7] chore: remove comments from studio e2e tests Co-Authored-By: Claude Opus 4.6 --- e2e/studio/handlers.test.ts | 3 --- e2e/studio/lifecycle.test.ts | 3 --- 2 files changed, 6 deletions(-) diff --git a/e2e/studio/handlers.test.ts b/e2e/studio/handlers.test.ts index 3503731b..bcd5b9a6 100644 --- a/e2e/studio/handlers.test.ts +++ b/e2e/studio/handlers.test.ts @@ -51,7 +51,6 @@ describe("Studio Handlers", () => { const original = process.env.PERSTACK_API_KEY delete process.env.PERSTACK_API_KEY try { - // Should reach the API and get a 404, NOT throw "PERSTACK_API_KEY is required" await expect( expertVersionsHandler("nonexistent-scope", { baseUrl: BASE_URL }), ).rejects.toThrow("Failed to list versions") @@ -72,7 +71,6 @@ describe("Studio Handlers", () => { console.log("PERSTACK_API_KEY not set, skipping") return } - // Should reach the API and get a 404, not an auth error await expect( expertVersionsHandler("nonexistent-scope", { apiKey, baseUrl: BASE_URL }), ).rejects.toThrow("Failed to list versions") @@ -91,7 +89,6 @@ describe("Studio Handlers", () => { it( "should list drafts without error", async () => { - // Should complete without throwing await expertListHandler({ apiKey, baseUrl: BASE_URL }) }, API_TIMEOUT, diff --git a/e2e/studio/lifecycle.test.ts b/e2e/studio/lifecycle.test.ts index f17d695d..0e1b161f 100644 --- a/e2e/studio/lifecycle.test.ts +++ b/e2e/studio/lifecycle.test.ts @@ -28,9 +28,6 @@ describe("Studio Lifecycle", () => { }) afterAll(async () => { - // Cleanup: attempt to delete the draft scope. - // This will fail with an FK constraint error if assignVersion created - // a linked expert_scope (no public API to delete expert_scopes yet). if (draftId) { await client.expertDrafts.delete(draftId).catch(() => {}) } From 84ed5b997eecf308d42a8f618bc016dd7d4617aa Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Wed, 25 Feb 2026 09:40:23 +0000 Subject: [PATCH 7/7] chore: add perstack patch bump to changeset Co-Authored-By: Claude Opus 4.6 --- .changeset/add-studio-expert-management.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/add-studio-expert-management.md b/.changeset/add-studio-expert-management.md index dfce6123..c1e75945 100644 --- a/.changeset/add-studio-expert-management.md +++ b/.changeset/add-studio-expert-management.md @@ -2,6 +2,7 @@ "@perstack/studio": patch "@perstack/installer": patch "@perstack/runtime": patch +"perstack": patch --- feat: add studio expert management commands to CLI