diff --git a/.changeset/lucky-snakes-cut.md b/.changeset/lucky-snakes-cut.md new file mode 100644 index 0000000..8534811 --- /dev/null +++ b/.changeset/lucky-snakes-cut.md @@ -0,0 +1,6 @@ +--- +"@plotday/sdk": patch +--- + +plot create --name argument +plot deploy --spec spec.md (alpha) diff --git a/sdk/cli/commands/build.ts b/sdk/cli/commands/build.ts new file mode 100644 index 0000000..96fd4d6 --- /dev/null +++ b/sdk/cli/commands/build.ts @@ -0,0 +1,86 @@ +import * as fs from "fs"; +import * as path from "path"; + +import * as out from "../utils/output"; +import { bundleAgent } from "../utils/bundle"; + +interface BuildOptions { + dir: string; +} + +/** + * Build command - bundles the agent without deploying. + * + * This command is useful for: + * - Testing that your agent builds successfully + * - Inspecting the bundled output + * - CI/CD pipelines that separate build and deploy steps + * + * @param options - Build configuration + */ +export async function buildCommand(options: BuildOptions) { + const agentPath = path.resolve(process.cwd(), options.dir); + + // Verify we're in an agent directory + const packageJsonPath = path.join(agentPath, "package.json"); + if (!fs.existsSync(packageJsonPath)) { + out.error( + "package.json not found. Are you in an agent directory?", + "Run this command from your agent's root directory" + ); + process.exit(1); + } + + // Read package.json for agent name + let agentName = "agent"; + try { + const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8"); + const packageJson = JSON.parse(packageJsonContent); + agentName = packageJson.displayName || packageJson.name || "agent"; + } catch (error) { + // Continue with default name if parsing fails + } + + out.header(`Building ${agentName}`); + + try { + // Bundle the agent + out.progress("Bundling..."); + const result = await bundleAgent(agentPath, { + minify: false, + sourcemap: true, + }); + + // Show warnings if any + if (result.warnings.length > 0) { + out.warning(`Build completed with ${result.warnings.length} warning(s)`); + for (const warning of result.warnings.slice(0, 10)) { + console.warn(` ${warning}`); + } + if (result.warnings.length > 10) { + console.warn(` ... and ${result.warnings.length - 10} more warnings`); + } + } + + // Get bundle stats + const buildDir = path.join(agentPath, "build"); + const bundlePath = path.join(buildDir, "index.js"); + const stats = fs.statSync(bundlePath); + const sizeKB = (stats.size / 1024).toFixed(2); + + // Show success + out.success(`Built successfully`); + out.plain(` Output: ${path.relative(process.cwd(), bundlePath)}`); + out.plain(` Size: ${sizeKB} KB`); + + // Tip for next steps + out.blank(); + out.plain("Next steps:"); + out.plain(" • Run 'plot agent deploy' to deploy this agent"); + out.plain(" • Or inspect the bundled output in build/index.js"); + out.blank(); + } catch (error) { + out.error("Build failed", String(error)); + process.exit(1); + } +} diff --git a/sdk/cli/commands/create.ts b/sdk/cli/commands/create.ts index fb9f4bf..f036322 100644 --- a/sdk/cli/commands/create.ts +++ b/sdk/cli/commands/create.ts @@ -6,6 +6,8 @@ import * as out from "../utils/output"; interface CreateOptions { dir?: string; + name?: string; + displayName?: string; } /** @@ -34,27 +36,53 @@ function detectPackageManager(): string { export async function createCommand(options: CreateOptions) { out.header("Create a new Plot agent"); - const response = await prompts([ - { - type: "text", - name: "name", - message: "Package name:", - initial: options.dir || undefined, - validate: (value: string) => - /^[a-z0-9-]+$/.test(value) || - "Must be kebab-case (lowercase, hyphens only)", - }, - { - type: "text", - name: "displayName", - message: "Display name:", - validate: (value: string) => value.length > 0 || "Name is required", - }, - ]); + let response: { name: string; displayName: string }; + + // If both name and displayName are provided via CLI, use them directly + if (options.name && options.displayName) { + // Validate name + if (!/^[a-z0-9-]+$/.test(options.name)) { + out.error("Name must be kebab-case (lowercase, hyphens only)"); + process.exit(1); + } + + // Validate displayName + if (options.displayName.length === 0) { + out.error("Display name is required"); + process.exit(1); + } - if (Object.keys(response).length === 0) { - out.plain("\nCancelled."); - process.exit(0); + response = { + name: options.name, + displayName: options.displayName, + }; + } else { + // Use interactive prompts + const promptResponse = await prompts([ + { + type: "text", + name: "name", + message: "Package name:", + initial: options.dir || options.name || undefined, + validate: (value: string) => + /^[a-z0-9-]+$/.test(value) || + "Must be kebab-case (lowercase, hyphens only)", + }, + { + type: "text", + name: "displayName", + message: "Display name:", + initial: options.displayName || undefined, + validate: (value: string) => value.length > 0 || "Name is required", + }, + ]); + + if (Object.keys(promptResponse).length === 0) { + out.plain("\nCancelled."); + process.exit(0); + } + + response = promptResponse as { name: string; displayName: string }; } const agentDir = options.dir || response.name; @@ -122,22 +150,18 @@ export async function createCommand(options: CreateOptions) { type Activity, Agent, type Tools, - createAgent, } from "@plotday/sdk"; -export default createAgent( - class extends Agent { - - constructor(tools: Tools) { - super(); - } +export default class extends Agent { + constructor(tools: Tools) { + super(); + } - async activity(activity: Activity) { - // Implement your agent logic here - console.log("Received activity:", activity); - } + async activity(activity: Activity) { + // Implement your agent logic here + console.log("Received activity:", activity); } -); +} `; fs.writeFileSync(path.join(agentPath, "src", "index.ts"), agentTemplate); diff --git a/sdk/cli/commands/deploy.ts b/sdk/cli/commands/deploy.ts index 8993f73..1b31beb 100644 --- a/sdk/cli/commands/deploy.ts +++ b/sdk/cli/commands/deploy.ts @@ -1,14 +1,15 @@ import * as dotenv from "dotenv"; -import * as esbuild from "esbuild"; import * as fs from "fs"; import * as path from "path"; import prompts from "prompts"; import * as out from "../utils/output"; import { getGlobalTokenPath } from "../utils/token"; +import { bundleAgent } from "../utils/bundle"; interface DeployOptions { dir: string; + spec?: string; id?: string; deployToken?: string; apiUrl: string; @@ -34,33 +35,82 @@ interface PackageJson { export async function deployCommand(options: DeployOptions) { const agentPath = path.resolve(process.cwd(), options.dir); + // Read spec file if provided + let specContent: string | undefined; + if (options.spec) { + const specPath = path.resolve(process.cwd(), options.spec); + if (!fs.existsSync(specPath)) { + out.error(`Spec file not found: ${options.spec}`); + process.exit(1); + } + try { + specContent = fs.readFileSync(specPath, "utf-8"); + } catch (error) { + out.error("Failed to read spec file", String(error)); + process.exit(1); + } + } + // Verify we're in an agent directory by checking for package.json + // Package.json is required when deploying source code, optional for spec const packageJsonPath = path.join(agentPath, "package.json"); - if (!fs.existsSync(packageJsonPath)) { - out.error( - "package.json not found. Are you in an agent directory?", - "Run this command from your agent's root directory" - ); - process.exit(1); - } + let packageJson: PackageJson | undefined; - // Read and validate package.json - let packageJson: PackageJson; - try { - const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8"); - packageJson = JSON.parse(packageJsonContent); - } catch (error) { - out.error("Failed to parse package.json", String(error)); - process.exit(1); + if (!options.spec) { + // Source code deployment requires package.json + if (!fs.existsSync(packageJsonPath)) { + out.error( + "package.json not found. Are you in an agent directory?", + "Run this command from your agent's root directory, or use --spec to deploy from a spec file" + ); + process.exit(1); + } + + // Read and validate package.json + try { + const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8"); + packageJson = JSON.parse(packageJsonContent); + } catch (error) { + out.error("Failed to parse package.json", String(error)); + process.exit(1); + } + } else if (fs.existsSync(packageJsonPath)) { + // Optional: read package.json for defaults when using spec + try { + const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8"); + packageJson = JSON.parse(packageJsonContent); + } catch { + // Ignore errors, package.json is optional for spec deployments + } } // Extract agent metadata from package.json - let agentId = packageJson.plotAgentId; - const agentName = packageJson.displayName; - const agentDescription = packageJson.description; + let agentId = packageJson?.plotAgentId; + const agentName = packageJson?.displayName; + const agentDescription = packageJson?.description; - // Validate required fields - if (!agentName) { + const environment = options.environment || "personal"; + + // For spec deployments without package.json, require CLI options + if (options.spec && !packageJson) { + if (!options.id) { + out.error( + "Agent ID is required when deploying spec without package.json", + "Provide --id flag" + ); + process.exit(1); + } + if (!options.name) { + out.error( + "Agent name is required when deploying spec without package.json", + "Provide --name flag" + ); + process.exit(1); + } + } + + // Validate required fields for source code deployments + if (!options.spec && !agentName) { out.error( "package.json is missing displayName", 'Add "displayName": "Your Agent Name" to package.json' @@ -68,11 +118,12 @@ export async function deployCommand(options: DeployOptions) { process.exit(1); } - const environment = options.environment || "personal"; - - // Validate Agent ID is present + // Validate Agent ID is present (from package.json or CLI) if (!agentId && !options.id) { - out.error("Agent ID missing", "Run 'plot agent create' to generate one."); + out.error( + "Agent ID missing", + "Run 'plot agent create' to generate one, or provide --id flag" + ); process.exit(1); } @@ -149,46 +200,53 @@ export async function deployCommand(options: DeployOptions) { } } - const entryPoint = path.join(agentPath, "src", "index.ts"); - if (!fs.existsSync(entryPoint)) { - out.error( - "src/index.ts not found", - "Your agent needs an entry point at src/index.ts" - ); - process.exit(1); - } - - const buildDir = path.join(agentPath, "build"); + // Prepare request body based on spec or source code + let requestBody: { + module?: string; + spec?: string; + name: string; + description?: string; + environment: string; + }; - // Clean build directory - if (fs.existsSync(buildDir)) { - fs.rmSync(buildDir, { recursive: true }); - } - fs.mkdirSync(buildDir, { recursive: true }); + try { + if (options.spec && specContent) { + // Deploying from spec + out.progress(`Deploying ${deploymentName} from spec...`); + + requestBody = { + spec: specContent, + name: deploymentName!, + description: deploymentDescription, + environment: environment, + }; + } else { + // Deploying from source code - build the agent + out.progress(`Building ${deploymentName}...`); + + const result = await bundleAgent(agentPath, { + minify: false, + sourcemap: true, + }); - // Show progress - out.progress(`Building ${agentName}...`); + const moduleContent = result.code; - try { - const result = await esbuild.build({ - entryPoints: [entryPoint], - bundle: true, - format: "esm", - platform: "browser", - target: "esnext", - outfile: path.join(buildDir, "index.js"), - sourcemap: true, - minify: false, - logLevel: "silent", - }); - - if (result.errors.length > 0) { - out.error("Build failed with errors"); - process.exit(1); - } + if (result.warnings.length > 0) { + out.warning("Build completed with warnings"); + for (const warning of result.warnings.slice(0, 5)) { + console.warn(` ${warning}`); + } + if (result.warnings.length > 5) { + console.warn(` ... and ${result.warnings.length - 5} more warnings`); + } + } - if (result.warnings.length > 0) { - out.warning("Build completed with warnings"); + requestBody = { + module: moduleContent, + name: deploymentName!, + description: deploymentDescription, + environment: environment, + }; } // Validate all required deployment fields @@ -209,27 +267,17 @@ export async function deployCommand(options: DeployOptions) { process.exit(1); } - const moduleContent = fs.readFileSync( - path.join(buildDir, "index.js"), - "utf-8" - ); - // Use deploymentId for non-personal, or "personal" for personal environment const urlPath = deploymentId || "personal"; try { const response = await fetch(`${options.apiUrl}/v1/agent/${urlPath}`, { - method: "PUT", + method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${deployToken}`, }, - body: JSON.stringify({ - module: moduleContent, - name: deploymentName, - description: deploymentDescription, - environment: environment, - }), + body: JSON.stringify(requestBody), }); if (!response.ok) { diff --git a/sdk/cli/index.ts b/sdk/cli/index.ts index 8827dd4..f6e98f4 100644 --- a/sdk/cli/index.ts +++ b/sdk/cli/index.ts @@ -3,6 +3,7 @@ import { Command, Option } from "commander"; import { readFileSync } from "fs"; import { join } from "path"; +import { buildCommand } from "./commands/build"; import { createCommand } from "./commands/create"; import { deployCommand } from "./commands/deploy"; import { lintCommand } from "./commands/lint"; @@ -52,6 +53,8 @@ agent .command("create") .description("Create a new Plot agent") .option("-d, --dir ", "Directory to create the agent in") + .option("-n, --name ", "Package name (kebab-case)") + .option("--display-name ", "Display name for the agent") .action(createCommand); agent @@ -60,10 +63,17 @@ agent .option("-d, --dir ", "Agent directory to lint", process.cwd()) .action(lintCommand); +agent + .command("build") + .description("Bundle the agent without deploying") + .option("-d, --dir ", "Agent directory to build", process.cwd()) + .action(buildCommand); + agent .command("deploy") .description("Bundle and deploy the agent") .option("-d, --dir ", "Agent directory to deploy", process.cwd()) + .option("--spec ", "Spec file to deploy (markdown)") .option("--id ", "Agent ID for deployment") .option("--deploy-token ", "Authentication token for deployment") .option("--name ", "Agent name") @@ -76,6 +86,7 @@ agent .action(function (this: Command) { const opts = this.optsWithGlobals() as { dir: string; + spec?: string; id?: string; deployToken?: string; apiUrl: string; diff --git a/sdk/cli/utils/bundle.ts b/sdk/cli/utils/bundle.ts new file mode 100644 index 0000000..58485d2 --- /dev/null +++ b/sdk/cli/utils/bundle.ts @@ -0,0 +1,87 @@ +import * as esbuild from "esbuild"; +import * as fs from "fs"; +import * as path from "path"; + +export interface BundleOptions { + minify?: boolean; + sourcemap?: boolean; +} + +export interface BundleResult { + code: string; + warnings: string[]; +} + +/** + * Bundles a Plot agent using esbuild. + * + * This function is shared between the build and deploy commands to ensure + * consistent bundling behavior. + * + * @param agentPath - Absolute path to the agent directory + * @param options - Optional bundling configuration + * @returns Promise resolving to the bundled code and any warnings + * @throws Error if bundling fails + */ +export async function bundleAgent( + agentPath: string, + options: BundleOptions = {} +): Promise { + const { minify = false, sourcemap = true } = options; + + // Validate agent path exists + if (!fs.existsSync(agentPath)) { + throw new Error(`Agent directory not found: ${agentPath}`); + } + + // Check for entry point + const entryPoint = path.join(agentPath, "src", "index.ts"); + if (!fs.existsSync(entryPoint)) { + throw new Error( + "src/index.ts not found. Your agent needs an entry point at src/index.ts" + ); + } + + // Create build directory + const buildDir = path.join(agentPath, "build"); + if (fs.existsSync(buildDir)) { + fs.rmSync(buildDir, { recursive: true }); + } + fs.mkdirSync(buildDir, { recursive: true }); + + // Bundle with esbuild + const result = await esbuild.build({ + entryPoints: [entryPoint], + bundle: true, + format: "esm", + platform: "browser", + target: "esnext", + outfile: path.join(buildDir, "index.js"), + sourcemap, + minify, + logLevel: "silent", + }); + + // Check for errors + if (result.errors.length > 0) { + const errorMessages = result.errors + .map((err) => `${err.location?.file}:${err.location?.line} - ${err.text}`) + .join("\n"); + throw new Error(`Build failed with errors:\n${errorMessages}`); + } + + // Collect warnings + const warnings = result.warnings.map( + (warn) => + `${warn.location?.file}:${warn.location?.line} - ${warn.text}` + ); + + // Read the bundled code + const bundlePath = path.join(buildDir, "index.js"); + const code = fs.readFileSync(bundlePath, "utf-8"); + + return { + code, + warnings, + }; +} diff --git a/sdk/src/tools/agent.ts b/sdk/src/tools/agent.ts index 1d6bd5d..798800a 100644 --- a/sdk/src/tools/agent.ts +++ b/sdk/src/tools/agent.ts @@ -52,12 +52,14 @@ export abstract class AgentManager extends ITool { * Deploys an agent programmatically. * * This method provides the same functionality as the plot agent deploy CLI - * command, but can be called from within an agent. It does not bundle the - * agent code - the module code must be provided pre-bundled. + * command, but can be called from within an agent. Accepts either: + * - A pre-bundled module (JavaScript code) + * - A spec (markdown text describing the functionality) - not yet implemented * * @param options - Deployment configuration * @param options.agentId - Agent ID for deployment - * @param options.module - Pre-bundled agent module code + * @param options.module - Pre-bundled agent module code (mutually exclusive with spec) + * @param options.spec - Markdown text describing agent functionality (mutually exclusive with module, not yet implemented) * @param options.environment - Target environment (defaults to "personal") * @param options.name - Optional agent name (required for first deploy) * @param options.description - Optional agent description (required for first deploy) @@ -66,6 +68,7 @@ export abstract class AgentManager extends ITool { * * @example * ```typescript + * // Deploy with a module * const result = await agent.deploy({ * agentId: 'abc-123-...', * module: 'export default class MyAgent extends Agent {...}', @@ -74,15 +77,36 @@ export abstract class AgentManager extends ITool { * description: 'Does something cool' * }); * console.log(`Deployed version ${result.version}`); + * + * // Deploy with a spec (not yet implemented, will throw error) + * const result = await agent.deploy({ + * agentId: 'abc-123-...', + * spec: '# My Agent\n\nDoes something cool', + * environment: 'personal', + * name: 'My Agent', + * }); * ``` */ - abstract deploy(_options: { - agentId: string; - module: string; - environment?: "personal" | "private" | "review"; - name: string; - description?: string; - }): Promise<{ + abstract deploy( + _options: ( + | { + agentId: string; + module: string; + spec?: never; + environment?: "personal" | "private" | "review"; + name: string; + description?: string; + } + | { + agentId: string; + spec: string; + module?: never; + environment?: "personal" | "private" | "review"; + name: string; + description?: string; + } + ) + ): Promise<{ version: string; }>;