Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/add-studio-expert-management.md
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 6 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
124 changes: 124 additions & 0 deletions apps/perstack/bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -147,6 +159,118 @@ program
await installHandler({ configPath, perstackConfig, envPath: options.envPath })
})

// Expert management commands
function getParentOptions(cmd: InstanceType<typeof Command>) {
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 <key>", "Perstack API key (default: PERSTACK_API_KEY env)")
.option("--base-url <url>", "Custom API base URL")

expertCmd
.command("list")
.description("List draft scopes")
.option("--filter <name>", "Filter by name")
.option("--take <n>", "Limit results", Number.parseInt)
.option("--skip <n>", "Offset", Number.parseInt)
.action(async function (this: InstanceType<typeof Command>, options) {
const parent = getParentOptions(this)
await expertListHandler({ ...parent, ...options })
})

expertCmd
.command("create")
.description("Create a new draft scope")
.argument("<scopeName>", "Expert scope name")
.requiredOption("--app <id>", "Application ID")
.action(async function (this: InstanceType<typeof Command>, scopeName, options) {
const parent = getParentOptions(this)
await expertCreateHandler(scopeName, { ...parent, ...options })
})

expertCmd
.command("delete")
.description("Delete a draft scope")
.argument("<draftId>", "Draft scope ID")
.action(async function (this: InstanceType<typeof Command>, draftId) {
const parent = getParentOptions(this)
await expertDeleteHandler(draftId, parent)
})

expertCmd
.command("push")
.description("Push local expert definitions to a draft ref")
.argument("<draftId>", "Draft scope ID")
.option("--config <path>", "Path to perstack.toml config file")
.action(async function (this: InstanceType<typeof Command>, draftId, options) {
const parent = getParentOptions(this)
await expertPushHandler(draftId, { ...parent, ...options })
})

expertCmd
.command("refs")
.description("List draft refs for a draft scope")
.argument("<draftId>", "Draft scope ID")
.option("--take <n>", "Limit results", Number.parseInt)
.option("--skip <n>", "Offset", Number.parseInt)
.action(async function (this: InstanceType<typeof Command>, draftId, options) {
const parent = getParentOptions(this)
await expertRefsHandler(draftId, { ...parent, ...options })
})

expertCmd
.command("version")
.description("Assign a version to a draft ref")
.argument("<draftId>", "Draft scope ID")
.argument("<refId>", "Draft ref ID")
.argument("<version>", "Semantic version (e.g., 1.0.0)")
.option("--tag <tag>", "Version tag (e.g., latest)")
.option("--readme <path>", "Path to README file")
.action(async function (this: InstanceType<typeof Command>, 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("<scopeName>", "Expert scope name")
.action(async function (this: InstanceType<typeof Command>, scopeName) {
const parent = getParentOptions(this)
await expertVersionsHandler(scopeName, parent)
})

expertCmd
.command("publish")
.description("Make an expert scope public")
.argument("<scopeName>", "Expert scope name")
.action(async function (this: InstanceType<typeof Command>, scopeName) {
const parent = getParentOptions(this)
await expertPublishHandler(scopeName, parent)
})

expertCmd
.command("unpublish")
.description("Make an expert scope private")
.argument("<scopeName>", "Expert scope name")
.action(async function (this: InstanceType<typeof Command>, scopeName) {
const parent = getParentOptions(this)
await expertUnpublishHandler(scopeName, parent)
})

expertCmd
.command("yank")
.description("Deprecate a specific expert version")
.argument("<key>", "Expert key with version (e.g., my-expert@1.0.0)")
.action(async function (this: InstanceType<typeof Command>, key) {
const parent = getParentOptions(this)
await expertYankHandler(key, parent)
})

program.parseAsync().catch((error) => {
if (error instanceof PerstackError) {
console.error(error.message)
Expand Down
1 change: 1 addition & 0 deletions apps/perstack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading