diff --git a/.changeset/add-studio-expert-management.md b/.changeset/add-studio-expert-management.md new file mode 100644 index 00000000..c1e75945 --- /dev/null +++ b/.changeset/add-studio-expert-management.md @@ -0,0 +1,13 @@ +--- +"@perstack/studio": patch +"@perstack/installer": patch +"@perstack/runtime": patch +"perstack": 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..bcd5b9a6 --- /dev/null +++ b/e2e/studio/handlers.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "bun:test" +import { PerstackError } from "../../packages/core/src/index.ts" +import { + expertListHandler, + expertVersionsHandler, + resolveApiKey, +} from "../../packages/studio/src/index.ts" + +const API_TIMEOUT = 30000 +const BASE_URL = "https://stg-api.perstack.ai" + +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("expertVersionsHandler", () => { + it( + "should work without API key (public endpoint)", + async () => { + const original = process.env.PERSTACK_API_KEY + delete process.env.PERSTACK_API_KEY + try { + await expect( + expertVersionsHandler("nonexistent-scope", { baseUrl: BASE_URL }), + ).rejects.toThrow("Failed to list versions") + } finally { + if (original) { + process.env.PERSTACK_API_KEY = original + } + } + }, + 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 + } + await expect( + expertVersionsHandler("nonexistent-scope", { apiKey, baseUrl: BASE_URL }), + ).rejects.toThrow("Failed to list versions") + }, + API_TIMEOUT, + ) + }) + + 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 () => { + await expertListHandler({ apiKey, baseUrl: BASE_URL }) + }, + API_TIMEOUT, + ) + }) +}) diff --git a/e2e/studio/lifecycle.test.ts b/e2e/studio/lifecycle.test.ts new file mode 100644 index 00000000..0e1b161f --- /dev/null +++ b/e2e/studio/lifecycle.test.ts @@ -0,0 +1,170 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test" +import { createStudioClient } from "../../packages/studio/src/index.ts" + +const API_TIMEOUT = 30000 +const BASE_URL = "https://stg-api.perstack.ai" + +describe("Studio Lifecycle", () => { + const apiKey = process.env.PERSTACK_API_KEY + if (!apiKey) { + it.skip("PERSTACK_API_KEY not set", () => {}) + return + } + + const client = createStudioClient({ apiKey, baseUrl: BASE_URL }) + 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 () => { + 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..5a69ebae --- /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..70e6ae1d --- /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 = 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) { + 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"] +}