diff --git a/README.md b/README.md index 3b35750..dce4593 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Manage ElevenLabs with local configuration files. This tool is an experimental e - **Smart Updates**: Hash-based change detection - **Import/Export**: Fetch existing agents and tools from workspace - **Tool Management**: Import and manage tools from ElevenLabs workspace +- **Branch Support**: Pull/push specific agent branches, CI/CD-friendly multi-branch workflows - **Widget Generation**: HTML widget snippets - **Secure Storage**: OS keychain integration with secure file fallback @@ -102,10 +103,12 @@ elevenlabs agents push ``` your_project/ -├── agents.json # Central configuration +├── agents.json # Central configuration (includes branch mappings) ├── tools.json # Tool configurations ├── tests.json # Test configurations ├── agent_configs/ # Agent configuration files +│ ├── My-Agent.json # Main branch config +│ └── My-Agent.staging.json # Branch config (auto-created by --all-branches) ├── tool_configs/ # Tool configurations └── test_configs/ # Test configurations ``` @@ -158,8 +161,14 @@ elevenlabs tools add-webhook "Tool Name" [--config-path path] # Create client tool elevenlabs tools add-client "Tool Name" [--config-path path] -# Push changes -elevenlabs agents push [--agent "Agent Name"] [--dry-run] +# Push changes (main + all registered branches) +elevenlabs agents push [--agent ] [--dry-run] + +# Push to a specific branch +elevenlabs agents push --agent --branch + +# List branches for an agent +elevenlabs agents branches list --agent [--include-archived] # Sync tools elevenlabs tools push [--tool "Tool Name"] [--dry-run] @@ -173,6 +182,12 @@ elevenlabs agents status [--agent "Agent Name"] # Pull agents from ElevenLabs elevenlabs agents pull [--search "term"] [--update] [--dry-run] +# Pull from a specific branch +elevenlabs agents pull --agent --branch + +# Pull all branches for each agent (stores as separate config files) +elevenlabs agents pull --all --all-branches + # Pull tools from ElevenLabs elevenlabs tools pull [--search "term"] [--tool "tool-name"] [--dry-run] [--output-dir tool_configs] @@ -339,13 +354,59 @@ elevenlabs agents list elevenlabs agents delete agent_123456789 ``` +## Branch Workflows + +Agent branches let you manage different configurations (e.g., staging vs production) alongside the main agent. Branch configs are stored as separate files in `agent_configs/` and tracked in `agents.json`, making them git-friendly and CI/CD-ready. + +**Work with branches locally:** + +```bash +# List branches for an agent +elevenlabs agents branches list --agent + +# Pull a specific branch config +elevenlabs agents pull --agent --branch staging + +# Push to a specific branch +elevenlabs agents push --agent --branch staging +``` + +**CI/CD pipeline (sync all branches):** + +```bash +elevenlabs agents pull --all --all-branches --update --no-ui +# Make config changes... +elevenlabs agents push --no-ui +# Push auto-pushes main + all registered branch configs +``` + +Branch configs in `agents.json`: + +```json +{ + "agents": [{ + "config": "agent_configs/My-Agent.json", + "id": "agent_123", + "branches": { + "staging": { + "config": "agent_configs/My-Agent.staging.json", + "branch_id": "agtbrch_xxx", + "version_id": "ver_xxx" + } + } + }] +} +``` + +The `--branch` flag accepts both human-readable names (`staging`) and branch IDs (`agtbrch_xxx`). + ## Workflow Examples ```bash # List all agents elevenlabs agents list -# Push all agents +# Push all agents (main + branches) elevenlabs agents push # Pull agents (skips existing local agents) diff --git a/src/__tests__/branches.test.ts b/src/__tests__/branches.test.ts new file mode 100644 index 0000000..71903aa --- /dev/null +++ b/src/__tests__/branches.test.ts @@ -0,0 +1,419 @@ +import { + getAgentApi, + updateAgentApi, + listBranchesApi, + resolveBranchId, +} from "../shared/elevenlabs-api"; +import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js"; + +describe("Agent branch support", () => { + function makeMockClient(opts: { + branches?: Array<{ + id: string; + name: string; + agentId: string; + description: string; + createdAt: number; + lastCommittedAt: number; + isArchived: boolean; + currentLivePercentage?: number; + }>; + } = {}) { + const update = jest.fn().mockResolvedValue({ + agentId: "agent_123", + versionId: "ver_abc", + branchId: "agtbrch_feat", + }); + const get = jest.fn().mockResolvedValue({ + agentId: "agent_123", + name: "Test Agent", + versionId: "ver_abc", + branchId: "agtbrch_feat", + conversationConfig: { + agent: { prompt: { prompt: "Hello", temperature: 0.5 } }, + }, + platformSettings: {}, + tags: [], + }); + const branchesList = jest.fn().mockResolvedValue({ + results: opts.branches ?? [ + { + id: "agtbrch_main123", + name: "main", + agentId: "agent_123", + description: "Main branch", + createdAt: 1700000000, + lastCommittedAt: 1700000000, + isArchived: false, + currentLivePercentage: 100, + }, + { + id: "agtbrch_feat456", + name: "staging", + agentId: "agent_123", + description: "Staging branch", + createdAt: 1700001000, + lastCommittedAt: 1700002000, + isArchived: false, + currentLivePercentage: 0, + }, + { + id: "agtbrch_old789", + name: "old-experiment", + agentId: "agent_123", + description: "Old experiment", + createdAt: 1699000000, + lastCommittedAt: 1699500000, + isArchived: true, + currentLivePercentage: 0, + }, + ], + }); + + return { + conversationalAi: { + agents: { + update, + get, + branches: { list: branchesList }, + }, + }, + } as unknown as ElevenLabsClient; + } + + describe("listBranchesApi", () => { + it("should return branches for an agent", async () => { + const client = makeMockClient(); + const branches = await listBranchesApi(client, "agent_123"); + + expect( + client.conversationalAi.agents.branches.list + ).toHaveBeenCalledWith("agent_123", { includeArchived: false }); + expect(branches).toHaveLength(3); + expect(branches[0].name).toBe("main"); + expect(branches[1].name).toBe("staging"); + }); + + it("should pass includeArchived flag", async () => { + const client = makeMockClient(); + await listBranchesApi(client, "agent_123", true); + + expect( + client.conversationalAi.agents.branches.list + ).toHaveBeenCalledWith("agent_123", { includeArchived: true }); + }); + + it("should return empty array when no branches exist", async () => { + const client = makeMockClient({ branches: [] }); + const branches = await listBranchesApi(client, "agent_123"); + + expect(branches).toHaveLength(0); + }); + }); + + describe("resolveBranchId", () => { + it("should return branch ID directly when input starts with agtbrch_", async () => { + const client = makeMockClient(); + const result = await resolveBranchId( + client, + "agent_123", + "agtbrch_feat456" + ); + + expect(result).toBe("agtbrch_feat456"); + // Should NOT call the API when given an ID directly + expect( + client.conversationalAi.agents.branches.list + ).not.toHaveBeenCalled(); + }); + + it("should resolve branch name to ID via API", async () => { + const client = makeMockClient(); + const result = await resolveBranchId(client, "agent_123", "staging"); + + expect(result).toBe("agtbrch_feat456"); + expect( + client.conversationalAi.agents.branches.list + ).toHaveBeenCalledWith("agent_123", { includeArchived: true }); + }); + + it("should throw error when branch name is not found", async () => { + const client = makeMockClient(); + + await expect( + resolveBranchId(client, "agent_123", "nonexistent") + ).rejects.toThrow( + "Branch 'nonexistent' not found for agent 'agent_123'" + ); + }); + + it("should include help message in error when branch not found", async () => { + const client = makeMockClient(); + + await expect( + resolveBranchId(client, "agent_123", "nonexistent") + ).rejects.toThrow( + "elevenlabs agents branches list --agent agent_123" + ); + }); + }); + + describe("getAgentApi with branchId", () => { + it("should call API without options when no branchId provided", async () => { + const client = makeMockClient(); + await getAgentApi(client, "agent_123"); + + expect(client.conversationalAi.agents.get).toHaveBeenCalledWith( + "agent_123" + ); + }); + + it("should call API with branchId when provided", async () => { + const client = makeMockClient(); + await getAgentApi(client, "agent_123", "agtbrch_feat456"); + + expect(client.conversationalAi.agents.get).toHaveBeenCalledWith( + "agent_123", + { branchId: "agtbrch_feat456" } + ); + }); + + it("should not pass branchId options when branchId is undefined", async () => { + const client = makeMockClient(); + await getAgentApi(client, "agent_123", undefined); + + // Should be called with just agentId, no second argument + expect(client.conversationalAi.agents.get).toHaveBeenCalledWith( + "agent_123" + ); + }); + }); + + describe("updateAgentApi with branchId", () => { + it("should not include branchId in payload when not provided", async () => { + const client = makeMockClient(); + const conversationConfig = { + agent: { prompt: { prompt: "hi", temperature: 0 } }, + } as unknown as Record; + + await updateAgentApi( + client, + "agent_123", + "Test Agent", + conversationConfig, + undefined, + undefined, + ["tag"], + "v1.0" + ); + + const [, payload] = ( + client.conversationalAi.agents.update as jest.Mock + ).mock.calls[0]; + + expect(payload.branchId).toBeUndefined(); + }); + + it("should include branchId in payload when provided", async () => { + const client = makeMockClient(); + const conversationConfig = { + agent: { prompt: { prompt: "hi", temperature: 0 } }, + } as unknown as Record; + + await updateAgentApi( + client, + "agent_123", + "Test Agent", + conversationConfig, + undefined, + undefined, + ["tag"], + "v1.0", + "agtbrch_feat456" + ); + + const [agentId, payload] = ( + client.conversationalAi.agents.update as jest.Mock + ).mock.calls[0]; + + expect(agentId).toBe("agent_123"); + expect(payload.branchId).toBe("agtbrch_feat456"); + }); + + it("should return branchId from API response", async () => { + const client = makeMockClient(); + const conversationConfig = { + agent: { prompt: { prompt: "hi", temperature: 0 } }, + } as unknown as Record; + + const result = await updateAgentApi( + client, + "agent_123", + "Test Agent", + conversationConfig, + undefined, + undefined, + [], + undefined, + "agtbrch_feat456" + ); + + expect(result.branchId).toBe("agtbrch_feat"); + }); + }); +}); + +describe("Branch persistence in agents.json", () => { + let tempDir: string; + let agentsConfigPath: string; + + beforeEach(async () => { + const fs = await import("fs-extra"); + const path = await import("path"); + const { tmpdir } = await import("os"); + tempDir = await fs.mkdtemp(path.join(tmpdir(), "test-branches-")); + agentsConfigPath = path.join(tempDir, "agents.json"); + }); + + afterEach(async () => { + const fs = await import("fs-extra"); + await fs.remove(tempDir); + }); + + it("should store branch configs in agents.json branches map", async () => { + const { writeConfig, readConfig } = await import("../shared/utils"); + + const agentsConfig = { + agents: [ + { + config: "agent_configs/My-Agent.json", + id: "agent_123", + version_id: "ver_abc", + branch_id: "agtbrch_main", + branches: { + staging: { + config: "agent_configs/My-Agent.staging.json", + branch_id: "agtbrch_feat456", + version_id: "ver_def", + }, + }, + }, + ], + }; + + await writeConfig(agentsConfigPath, agentsConfig); + const loaded = (await readConfig(agentsConfigPath)) as typeof agentsConfig; + + expect(loaded.agents[0].branches).toBeDefined(); + expect(loaded.agents[0].branches!.staging).toBeDefined(); + expect(loaded.agents[0].branches!.staging.config).toBe( + "agent_configs/My-Agent.staging.json" + ); + expect(loaded.agents[0].branches!.staging.branch_id).toBe( + "agtbrch_feat456" + ); + expect(loaded.agents[0].branches!.staging.version_id).toBe("ver_def"); + }); + + it("should support multiple branches per agent", async () => { + const { writeConfig, readConfig } = await import("../shared/utils"); + + const agentsConfig = { + agents: [ + { + config: "agent_configs/My-Agent.json", + id: "agent_123", + version_id: "ver_abc", + branch_id: "agtbrch_main", + branches: { + staging: { + config: "agent_configs/My-Agent.staging.json", + branch_id: "agtbrch_stag", + version_id: "ver_1", + }, + "dev-experiment": { + config: "agent_configs/My-Agent.dev-experiment.json", + branch_id: "agtbrch_dev", + version_id: "ver_2", + }, + }, + }, + ], + }; + + await writeConfig(agentsConfigPath, agentsConfig); + const loaded = (await readConfig(agentsConfigPath)) as typeof agentsConfig; + + expect(Object.keys(loaded.agents[0].branches!)).toHaveLength(2); + expect(loaded.agents[0].branches!.staging.branch_id).toBe("agtbrch_stag"); + expect(loaded.agents[0].branches!["dev-experiment"].branch_id).toBe( + "agtbrch_dev" + ); + }); + + it("should be backward compatible with agents without branches", async () => { + const { writeConfig, readConfig } = await import("../shared/utils"); + + const agentsConfig = { + agents: [ + { + config: "agent_configs/Old-Agent.json", + id: "agent_old", + version_id: "ver_old", + }, + { + config: "agent_configs/New-Agent.json", + id: "agent_new", + version_id: "ver_new", + branches: { + staging: { + config: "agent_configs/New-Agent.staging.json", + branch_id: "agtbrch_stag", + version_id: "ver_stag", + }, + }, + }, + ], + }; + + await writeConfig(agentsConfigPath, agentsConfig); + const loaded = (await readConfig(agentsConfigPath)) as typeof agentsConfig; + + // Old agent has no branches property + expect((loaded.agents[0] as any).branches).toBeUndefined(); + // New agent has branches + expect(loaded.agents[1].branches).toBeDefined(); + expect(loaded.agents[1].branches!.staging.branch_id).toBe("agtbrch_stag"); + }); + + it("should update branch version_id on subsequent pushes", async () => { + const { writeConfig, readConfig } = await import("../shared/utils"); + + const agentsConfig = { + agents: [ + { + config: "agent_configs/My-Agent.json", + id: "agent_123", + branches: { + staging: { + config: "agent_configs/My-Agent.staging.json", + branch_id: "agtbrch_stag", + version_id: "ver_1", + }, + }, + }, + ], + }; + + await writeConfig(agentsConfigPath, agentsConfig); + + // Simulate push updating the branch version + const loaded = (await readConfig(agentsConfigPath)) as typeof agentsConfig; + loaded.agents[0].branches!.staging.version_id = "ver_2"; + await writeConfig(agentsConfigPath, loaded); + + const final = (await readConfig(agentsConfigPath)) as typeof agentsConfig; + expect(final.agents[0].branches!.staging.version_id).toBe("ver_2"); + expect(final.agents[0].branches!.staging.branch_id).toBe("agtbrch_stag"); + }); +}); diff --git a/src/agents/commands/branches-impl.ts b/src/agents/commands/branches-impl.ts new file mode 100644 index 0000000..8c7cb78 --- /dev/null +++ b/src/agents/commands/branches-impl.ts @@ -0,0 +1,43 @@ +import { getElevenLabsClient, listBranchesApi } from '../../shared/elevenlabs-api.js'; + +interface BranchesListOptions { + agent: string; + includeArchived: boolean; +} + +export async function listBranches(options: BranchesListOptions): Promise { + const client = await getElevenLabsClient(); + + console.log(`Listing branches for agent: ${options.agent}...`); + + const branches = await listBranchesApi(client, options.agent, options.includeArchived); + + if (branches.length === 0) { + console.log('No branches found for this agent.'); + return; + } + + // Print table header + const nameCol = 25; + const idCol = 40; + const statusCol = 12; + const trafficCol = 10; + + console.log( + `${'NAME'.padEnd(nameCol)}${'BRANCH ID'.padEnd(idCol)}${'STATUS'.padEnd(statusCol)}${'TRAFFIC'.padEnd(trafficCol)}LAST UPDATED` + ); + console.log('─'.repeat(110)); + + for (const branch of branches) { + const name = branch.name.length > nameCol - 2 ? branch.name.slice(0, nameCol - 5) + '...' : branch.name; + const status = branch.isArchived ? 'archived' : 'active'; + const traffic = `${branch.currentLivePercentage ?? 0}%`; + const lastUpdated = new Date(branch.lastCommittedAt * 1000).toISOString().split('T')[0]; + + console.log( + `${name.padEnd(nameCol)}${branch.id.padEnd(idCol)}${status.padEnd(statusCol)}${traffic.padEnd(trafficCol)}${lastUpdated}` + ); + } + + console.log(`\n${branches.length} branch(es) found`); +} diff --git a/src/agents/commands/branches.ts b/src/agents/commands/branches.ts new file mode 100644 index 0000000..bb5b55d --- /dev/null +++ b/src/agents/commands/branches.ts @@ -0,0 +1,42 @@ +import { Command } from 'commander'; +import { render } from 'ink'; +import React from 'react'; +import BranchesListView from '../ui/BranchesListView.js'; +import { listBranches } from './branches-impl.js'; + +interface BranchesListOptions { + agent: string; + includeArchived: boolean; +} + +export function createBranchesCommand(): Command { + const branches = new Command('branches') + .description('Manage agent branches'); + + branches + .command('list') + .description('List branches for an agent') + .requiredOption('--agent ', 'Agent ID to list branches for') + .option('--include-archived', 'Include archived branches', false) + .option('--no-ui', 'Disable interactive UI') + .action(async (options: BranchesListOptions & { ui: boolean }) => { + try { + if (options.ui !== false) { + const { waitUntilExit } = render( + React.createElement(BranchesListView, { + agent: options.agent, + includeArchived: options.includeArchived + }) + ); + await waitUntilExit(); + } else { + await listBranches(options); + } + } catch (error) { + console.error(`Error listing branches: ${error}`); + process.exit(1); + } + }); + + return branches; +} diff --git a/src/agents/commands/index.ts b/src/agents/commands/index.ts index f895330..066d4eb 100644 --- a/src/agents/commands/index.ts +++ b/src/agents/commands/index.ts @@ -11,6 +11,7 @@ import { createPullCommand } from './pull.js'; import { createTemplatesCommand } from './templates.js'; import { createWidgetCommand } from './widget.js'; import { createTestCommand } from './test.js'; +import { createBranchesCommand } from './branches.js'; import AgentsHelpView from '../ui/AgentsHelpView.js'; export function createAgentsCommand(): Command { @@ -43,6 +44,7 @@ export function createAgentsCommand(): Command { agents.addCommand(createTemplatesCommand()); agents.addCommand(createWidgetCommand()); agents.addCommand(createTestCommand()); + agents.addCommand(createBranchesCommand()); return agents; } diff --git a/src/agents/commands/init.ts b/src/agents/commands/init.ts index 24feb01..19a3424 100644 --- a/src/agents/commands/init.ts +++ b/src/agents/commands/init.ts @@ -118,6 +118,9 @@ ELEVENLABS_API_KEY=your_api_key_here console.log('4. Create tests: elevenlabs tests add "My Test" --template basic-llm'); console.log('5. Push to ElevenLabs: elevenlabs agents push && elevenlabs tools push && elevenlabs tests push'); console.log('6. Run tests: elevenlabs agents test "My Agent"'); + console.log('\nBranch workflow (CI/CD):'); + console.log(' Pull all branches: elevenlabs agents pull --all --all-branches'); + console.log(' Push all (main + branches): elevenlabs agents push'); } } catch (error) { console.error(`Error initializing project: ${error}`); diff --git a/src/agents/commands/pull-impl.ts b/src/agents/commands/pull-impl.ts index 75e7cae..ed50efa 100644 --- a/src/agents/commands/pull-impl.ts +++ b/src/agents/commands/pull-impl.ts @@ -1,17 +1,24 @@ import path from 'path'; import fs from 'fs-extra'; import { readConfig, writeConfig, generateUniqueFilename } from '../../shared/utils.js'; -import { getElevenLabsClient, listAgentsApi, getAgentApi } from '../../shared/elevenlabs-api.js'; +import { getElevenLabsClient, listAgentsApi, getAgentApi, resolveBranchId, listBranchesApi } from '../../shared/elevenlabs-api.js'; import { AgentConfig } from '../templates.js'; import { promptForConfirmation } from './utils.js'; const AGENTS_CONFIG_FILE = "agents.json"; +interface BranchDefinition { + config: string; + branch_id: string; + version_id?: string; +} + interface AgentDefinition { config: string; id?: string; branch_id?: string; version_id?: string; + branches?: Record; } interface AgentsConfig { @@ -20,6 +27,8 @@ interface AgentsConfig { interface PullOptions { agent?: string; + branch?: string; + allBranches?: boolean; outputDir: string; dryRun: boolean; update?: boolean; @@ -37,6 +46,13 @@ export async function pullAgents(options: PullOptions): Promise { async function pullAgentsFromEnvironment(options: PullOptions, agentsConfigPath: string): Promise { const client = await getElevenLabsClient(); + // Resolve branch ID if specified + let branchId: string | undefined; + if (options.branch && options.agent) { + console.log(`Pulling from branch: ${options.branch}`); + branchId = await resolveBranchId(client, options.agent, options.branch); + } + // Load existing config let agentsConfig: AgentsConfig; @@ -54,7 +70,7 @@ async function pullAgentsFromEnvironment(options: PullOptions, agentsConfigPath: // Pull specific agent by ID console.log(`Pulling agent with ID: ${options.agent}...`); try { - const agentDetails = await getAgentApi(client, options.agent); + const agentDetails = await getAgentApi(client, options.agent, branchId); const agentDetailsTyped = agentDetails as { agentId?: string; agent_id?: string; name: string }; const agentId = agentDetailsTyped.agentId || agentDetailsTyped.agent_id || options.agent; agentsList = [{ @@ -169,7 +185,7 @@ async function pullAgentsFromEnvironment(options: PullOptions, agentsConfigPath: try { // Fetch detailed agent configuration console.log(`${action === 'update' ? '↻ Updating' : '+ Pulling'} config for '${agent.name}'...`); - const agentDetails = await getAgentApi(client, agent.id); + const agentDetails = await getAgentApi(client, agent.id, branchId); // Extract configuration components const agentDetailsTyped = agentDetails as { @@ -201,6 +217,8 @@ async function pullAgentsFromEnvironment(options: PullOptions, agentsConfigPath: agentConfig.workflow = workflow; } + let agentEntry: AgentDefinition; + if (action === 'update' && existingEntry) { // Update existing entry - overwrite the config file const configFilePath = path.resolve(existingEntry.config); @@ -211,6 +229,7 @@ async function pullAgentsFromEnvironment(options: PullOptions, agentsConfigPath: if (agentDetailsTyped.version_id) existingEntry.version_id = agentDetailsTyped.version_id; if (agentDetailsTyped.branch_id) existingEntry.branch_id = agentDetailsTyped.branch_id; + agentEntry = existingEntry; console.log(` ✓ Updated '${agent.name}' (config: ${existingEntry.config})`); } else { // Create new entry @@ -227,9 +246,34 @@ async function pullAgentsFromEnvironment(options: PullOptions, agentsConfigPath: }; agentsConfig.agents.push(newAgent); + agentEntry = newAgent; console.log(` ✓ Added '${agent.name}' (config: ${configPath})`); } + // If --branch was specified, store the branch config persistently + if (options.branch && branchId) { + const branchConfigPath = await generateUniqueFilename( + options.outputDir, + `${agent.name}.${options.branch}` + ); + const branchConfigFilePath = path.resolve(branchConfigPath); + await fs.ensureDir(path.dirname(branchConfigFilePath)); + await writeConfig(branchConfigFilePath, agentConfig); + + if (!agentEntry.branches) agentEntry.branches = {}; + agentEntry.branches[options.branch] = { + config: branchConfigPath, + branch_id: branchId, + version_id: agentDetailsTyped.version_id + }; + console.log(` ✓ Stored branch '${options.branch}' config (${branchConfigPath})`); + } + + // If --all-branches was specified, pull all branches + if (options.allBranches && agent.id) { + await pullAllBranches(client, agent.id, agent.name, agentEntry, options); + } + itemsProcessed++; } catch (error) { @@ -254,3 +298,91 @@ async function pullAgentsFromEnvironment(options: PullOptions, agentsConfigPath: } } } + +async function pullAllBranches( + client: Awaited>, + agentId: string, + agentName: string, + agentEntry: AgentDefinition, + options: PullOptions +): Promise { + console.log(` Fetching branches for '${agentName}'...`); + const branches = await listBranchesApi(client, agentId); + + if (branches.length === 0) { + console.log(` No branches found for '${agentName}'`); + return; + } + + if (!agentEntry.branches) agentEntry.branches = {}; + + for (const branch of branches) { + if (branch.isArchived) continue; + + // Skip if this is the main branch (same as agent's branch_id, or name "main" when branch_id is unset) + if (branch.id === agentEntry.branch_id || (!agentEntry.branch_id && branch.name === 'main')) continue; + + const branchName = branch.name; + + if (options.dryRun) { + console.log(` [DRY RUN] Would pull branch '${branchName}'`); + continue; + } + + try { + const branchDetails = await getAgentApi(client, agentId, branch.id); + const branchDetailsTyped = branchDetails as { + conversation_config: Record; + platform_settings: Record; + workflow?: unknown; + tags: string[]; + version_id?: string; + }; + + const branchConfig: AgentConfig = { + name: agentName, + conversation_config: branchDetailsTyped.conversation_config as AgentConfig['conversation_config'], + platform_settings: branchDetailsTyped.platform_settings, + tags: branchDetailsTyped.tags || [] + }; + + if (branchDetailsTyped.workflow !== undefined && branchDetailsTyped.workflow !== null) { + branchConfig.workflow = branchDetailsTyped.workflow; + } + + // Determine config path for this branch + const existingBranch = agentEntry.branches[branchName]; + let branchConfigPath: string; + + if (existingBranch) { + // Update existing branch config file + branchConfigPath = existingBranch.config; + } else { + // Create new branch config file + branchConfigPath = await generateUniqueFilename( + options.outputDir, + `${agentName}.${branchName}` + ); + } + + const branchConfigFilePath = path.resolve(branchConfigPath); + await fs.ensureDir(path.dirname(branchConfigFilePath)); + await writeConfig(branchConfigFilePath, branchConfig); + + agentEntry.branches[branchName] = { + config: branchConfigPath, + branch_id: branch.id, + version_id: branchDetailsTyped.version_id + }; + + console.log(` ✓ Branch '${branchName}' (${branchConfigPath})`); + } catch (error) { + console.log(` ✗ Error pulling branch '${branchName}': ${error}`); + } + } + + const branchCount = Object.keys(agentEntry.branches).length; + if (branchCount > 0) { + console.log(` ${branchCount} branch(es) stored`); + } +} diff --git a/src/agents/commands/pull.ts b/src/agents/commands/pull.ts index e289495..60765c4 100644 --- a/src/agents/commands/pull.ts +++ b/src/agents/commands/pull.ts @@ -6,6 +6,8 @@ import { pullAgents } from './pull-impl.js'; interface PullOptions { agent?: string; + branch?: string; + allBranches?: boolean; outputDir: string; dryRun: boolean; update?: boolean; @@ -16,6 +18,8 @@ export function createPullCommand(): Command { return new Command('pull') .description('Pull agents from ElevenLabs') .option('--agent ', 'Specific agent ID to pull') + .option('--branch ', 'Specific branch name or ID to pull from') + .option('--all-branches', 'Pull all branches for each agent', false) .option('--output-dir ', 'Output directory for configs', 'agent_configs') .option('--dry-run', 'Show what would be done without making changes', false) .option('--update', 'Update existing items only, skip new') @@ -23,11 +27,21 @@ export function createPullCommand(): Command { .option('--no-ui', 'Disable interactive UI') .action(async (options: PullOptions & { ui: boolean }) => { try { + if (options.branch && !options.agent) { + throw new Error('--branch requires --agent to be specified, since branch names are per-agent.'); + } + // --all-branches requires the non-UI codepath (the UI view doesn't support it) + if (options.allBranches) { + await pullAgents(options); + return; + } if (options.ui !== false) { // Use Ink UI for pull const { waitUntilExit } = render( React.createElement(PullView, { agent: options.agent, + branch: options.branch, + allBranches: options.allBranches, outputDir: options.outputDir, dryRun: options.dryRun, update: options.update, diff --git a/src/agents/commands/push-impl.ts b/src/agents/commands/push-impl.ts index 12da370..8d44667 100644 --- a/src/agents/commands/push-impl.ts +++ b/src/agents/commands/push-impl.ts @@ -1,23 +1,30 @@ import path from 'path'; import fs from 'fs-extra'; import { readConfig, writeConfig } from '../../shared/utils.js'; -import { getElevenLabsClient, createAgentApi, updateAgentApi } from '../../shared/elevenlabs-api.js'; +import { getElevenLabsClient, createAgentApi, updateAgentApi, resolveBranchId } from '../../shared/elevenlabs-api.js'; import { AgentConfig } from '../templates.js'; const AGENTS_CONFIG_FILE = "agents.json"; +interface BranchDefinition { + config: string; + branch_id: string; + version_id?: string; +} + interface AgentDefinition { config: string; id?: string; branch_id?: string; version_id?: string; + branches?: Record; } interface AgentsConfig { agents: AgentDefinition[]; } -export async function pushAgents(dryRun: boolean = false, agentId?: string, versionDescription?: string): Promise { +export async function pushAgents(dryRun: boolean = false, agentId?: string, versionDescription?: string, branch?: string): Promise { // Load agents configuration const agentsConfigPath = path.resolve(AGENTS_CONFIG_FILE); if (!(await fs.pathExists(agentsConfigPath))) { @@ -65,13 +72,20 @@ export async function pushAgents(dryRun: boolean = false, agentId?: string, vers const agentDefName = agentConfig.name || 'Unnamed Agent'; // Get agent ID from index file - const agentId = agentDef.id; + const currentAgentId = agentDef.id; // Always push (force override) console.log(`${agentDefName}: Will push (force override)`); if (dryRun) { console.log(`[DRY RUN] Would update agent: ${agentDefName}`); + if (branch) { + console.log(` [DRY RUN] Would push to branch '${branch}'`); + } else if (agentDef.branches && currentAgentId) { + for (const branchName of Object.keys(agentDef.branches)) { + console.log(` [DRY RUN] Would push branch '${branchName}'`); + } + } continue; } @@ -85,6 +99,13 @@ export async function pushAgents(dryRun: boolean = false, agentId?: string, vers continue; } + // Resolve branch ID if specified + let branchId: string | undefined; + if (branch && currentAgentId) { + branchId = await resolveBranchId(client, currentAgentId, branch); + console.log(`Pushing to branch: ${branch}`); + } + // Perform API operation try { // Extract config components @@ -95,7 +116,7 @@ export async function pushAgents(dryRun: boolean = false, agentId?: string, vers const agentDisplayName = agentConfig.name; - if (!agentId) { + if (!currentAgentId) { // Create new agent const newAgentId = await createAgentApi( client, @@ -114,15 +135,16 @@ export async function pushAgents(dryRun: boolean = false, agentId?: string, vers // Update existing agent const result = await updateAgentApi( client, - agentId, + currentAgentId, agentDisplayName, conversationConfig, platformSettings, workflow, tags, - versionDescription + versionDescription, + branchId ); - console.log(`Updated agent ${agentDefName} (ID: ${agentId})`); + console.log(`Updated agent ${agentDefName} (ID: ${currentAgentId})`); // Update version/branch info if (result.versionId) agentDef.version_id = result.versionId; @@ -131,6 +153,42 @@ export async function pushAgents(dryRun: boolean = false, agentId?: string, vers changesMade = true; + // Push all registered branch configs (unless a specific --branch was given) + if (!branch && agentDef.branches && currentAgentId) { + for (const [branchName, branchDef] of Object.entries(agentDef.branches)) { + try { + if (!(await fs.pathExists(branchDef.config))) { + console.log(` Warning: Branch config file not found: ${branchDef.config}`); + continue; + } + + const branchConfig = await readConfig(branchDef.config); + const branchConversationConfig = branchConfig.conversation_config || {}; + const branchPlatformSettings = branchConfig.platform_settings; + const branchWorkflow = branchConfig.workflow; + const branchTags = branchConfig.tags || []; + + console.log(` Pushing branch '${branchName}'...`); + const branchResult = await updateAgentApi( + client, + currentAgentId, + branchConfig.name, + branchConversationConfig, + branchPlatformSettings, + branchWorkflow, + branchTags, + versionDescription, + branchDef.branch_id + ); + + if (branchResult.versionId) branchDef.version_id = branchResult.versionId; + console.log(` ✓ Pushed branch '${branchName}'`); + } catch (error) { + console.log(` ✗ Error pushing branch '${branchName}': ${error}`); + } + } + } + } catch (error) { console.log(`Error processing ${agentDefName}: ${error}`); } diff --git a/src/agents/commands/push.ts b/src/agents/commands/push.ts index 150f610..c7061e8 100644 --- a/src/agents/commands/push.ts +++ b/src/agents/commands/push.ts @@ -19,6 +19,7 @@ interface AgentsConfig { interface PushOptions { agent?: string; + branch?: string; dryRun: boolean; versionDescription?: string; } @@ -27,11 +28,15 @@ export function createPushCommand(): Command { return new Command('push') .description('Push agents to ElevenLabs API when configs change') .option('--agent ', 'Specific agent ID to push') + .option('--branch ', 'Specific branch name or ID to push to') .option('--dry-run', 'Show what would be done without making changes', false) .option('--version-description ', 'Description for the new version (only applies to updates)') .option('--no-ui', 'Disable interactive UI') .action(async (options: PushOptions & { ui: boolean }) => { try { + if (options.branch && !options.agent) { + throw new Error('--branch requires --agent to be specified, since branch names are per-agent.'); + } if (options.ui !== false) { // Use new Ink UI for push const agentsConfigPath = path.resolve(AGENTS_CONFIG_FILE); @@ -64,13 +69,14 @@ export function createPushCommand(): Command { React.createElement(PushView, { agents: pushAgentsData, dryRun: options.dryRun, - versionDescription: options.versionDescription + versionDescription: options.versionDescription, + branch: options.branch }) ); await waitUntilExit(); } else { // Use existing non-UI push - await pushAgents(options.dryRun, options.agent, options.versionDescription); + await pushAgents(options.dryRun, options.agent, options.versionDescription, options.branch); } } catch (error) { console.error(`Error during push: ${error}`); diff --git a/src/agents/ui/AgentsHelpView.tsx b/src/agents/ui/AgentsHelpView.tsx index ac7767d..995eb9e 100644 --- a/src/agents/ui/AgentsHelpView.tsx +++ b/src/agents/ui/AgentsHelpView.tsx @@ -45,15 +45,28 @@ const commands: Command[] = [ { name: "push", description: "Push agents to ElevenLabs", + options: [ + { flag: "--branch ", description: "Push to a specific branch" }, + ], }, { name: "pull", description: "Pull agents from ElevenLabs", options: [ + { flag: "--branch ", description: "Pull from a specific branch" }, + { flag: "--all-branches", description: "Pull all branches for each agent" }, { flag: "--update", description: "Update existing agents" }, { flag: "--all", description: "Pull all agents" }, ], }, + { + name: "branches list", + description: "List branches for an agent", + options: [ + { flag: "--agent ", description: "Agent ID (required)" }, + { flag: "--include-archived", description: "Include archived branches" }, + ], + }, { name: "test ", description: "Run tests for an agent", diff --git a/src/agents/ui/BranchesListView.tsx b/src/agents/ui/BranchesListView.tsx new file mode 100644 index 0000000..bc0cb86 --- /dev/null +++ b/src/agents/ui/BranchesListView.tsx @@ -0,0 +1,126 @@ +import React, { useState, useEffect } from 'react'; +import { Box, Text, useApp } from 'ink'; +import App from '../../ui/App.js'; +import theme from '../../ui/themes/elevenlabs.js'; +import { getElevenLabsClient, listBranchesApi } from '../../shared/elevenlabs-api.js'; +import type { ElevenLabs } from '@elevenlabs/elevenlabs-js'; + +interface BranchesListViewProps { + agent: string; + includeArchived: boolean; + onComplete?: () => void; +} + +export const BranchesListView: React.FC = ({ + agent, + includeArchived, + onComplete +}) => { + const { exit } = useApp(); + const [branches, setBranches] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchBranches = async () => { + try { + const client = await getElevenLabsClient(); + const results = await listBranchesApi(client, agent, includeArchived); + setBranches(results); + setLoading(false); + + setTimeout(() => { + if (onComplete) onComplete(); + else exit(); + }, 2000); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to list branches'); + setLoading(false); + setTimeout(() => { + if (onComplete) onComplete(); + else exit(); + }, 2000); + } + }; + + fetchBranches(); + }, []); + + return ( + + + {error ? ( + ✗ {error} + ) : loading ? ( + Loading branches... + ) : branches.length === 0 ? ( + No branches found for this agent. + ) : ( + <> + + Branches: + + + {/* Table Header */} + + + NAME + + + BRANCH ID + + + STATUS + + + TRAFFIC + + + LAST UPDATED + + + + + {'─'.repeat(100)} + + + {branches.map((branch) => { + const status = branch.isArchived ? 'archived' : 'active'; + const statusColor = branch.isArchived ? theme.colors.text.muted : theme.colors.success; + const traffic = `${branch.currentLivePercentage ?? 0}%`; + const lastUpdated = new Date(branch.lastCommittedAt * 1000).toISOString().split('T')[0]; + + return ( + + + {branch.name} + + + {branch.id} + + + {status} + + + {traffic} + + + {lastUpdated} + + + ); + })} + + + + {branches.length} branch(es) found + + + + )} + + + ); +}; + +export default BranchesListView; diff --git a/src/agents/ui/InitView.tsx b/src/agents/ui/InitView.tsx index 531557e..9ff0e94 100644 --- a/src/agents/ui/InitView.tsx +++ b/src/agents/ui/InitView.tsx @@ -261,6 +261,10 @@ export const InitView: React.FC = ({ projectPath, override = fals 1. Set your API key: elevenlabs auth login 2. Create an agent: elevenlabs agents add "My Agent" --template default 3. Push to ElevenLabs: elevenlabs agents push + + Branch workflow (CI/CD): + Pull all branches: elevenlabs agents pull --all --all-branches + Push all (main + branches): elevenlabs agents push )} diff --git a/src/agents/ui/PullView.tsx b/src/agents/ui/PullView.tsx index 87aef31..00c0441 100644 --- a/src/agents/ui/PullView.tsx +++ b/src/agents/ui/PullView.tsx @@ -5,7 +5,7 @@ import theme from '../../ui/themes/elevenlabs.js'; import path from 'path'; import fs from 'fs-extra'; import { readConfig, writeConfig, generateUniqueFilename } from '../../shared/utils.js'; -import { getElevenLabsClient, listAgentsApi, getAgentApi } from '../../shared/elevenlabs-api.js'; +import { getElevenLabsClient, listAgentsApi, getAgentApi, resolveBranchId } from '../../shared/elevenlabs-api.js'; interface PullAgent { name: string; @@ -18,6 +18,8 @@ interface PullAgent { interface PullViewProps { agent?: string; // Agent ID to pull specifically + branch?: string; // Branch name or ID to pull from + allBranches?: boolean; // Pull all branches for each agent outputDir: string; dryRun: boolean; update?: boolean; @@ -27,6 +29,8 @@ interface PullViewProps { export const PullView: React.FC = ({ agent, + branch, + allBranches, outputDir, dryRun, update, @@ -57,11 +61,17 @@ export const PullView: React.FC = ({ const client = await getElevenLabsClient(); + // Resolve branch ID if specified + let branchId: string | undefined; + if (branch && agent) { + branchId = await resolveBranchId(client, agent, branch); + } + // Fetch agents list - either specific agent by ID or all agents let agentsList: unknown[]; if (agent) { // Pull specific agent by ID - const agentDetails = await getAgentApi(client, agent); + const agentDetails = await getAgentApi(client, agent, branchId); const agentDetailsTyped = agentDetails as { agentId?: string; agent_id?: string; name: string }; const agentId = agentDetailsTyped.agentId || agentDetailsTyped.agent_id || agent; agentsList = [{ @@ -141,7 +151,7 @@ export const PullView: React.FC = ({ // Start processing if there are agents to pull if (allAgentsToPull.some(a => a.status === 'pending')) { // eslint-disable-next-line @typescript-eslint/no-floating-promises - processNextAgent(allAgentsToPull, 0, agentsConfig, agentsConfigPath); + processNextAgent(allAgentsToPull, 0, agentsConfig, agentsConfigPath, branchId); } else { setComplete(true); setTimeout(() => { @@ -163,7 +173,8 @@ export const PullView: React.FC = ({ agentsList: PullAgent[], index: number, agentsConfig: any, - agentsConfigPath: string + agentsConfigPath: string, + branchId?: string ): Promise => { if (index >= agentsList.length) { // Save files after all agents are processed (if not dry run) @@ -183,7 +194,7 @@ export const PullView: React.FC = ({ // Skip if already marked as skipped if (agent.status === 'skipped') { setCurrentIndex(index + 1); - processNextAgent(agentsList, index + 1, agentsConfig, agentsConfigPath); + processNextAgent(agentsList, index + 1, agentsConfig, agentsConfigPath, branchId); return; } @@ -209,8 +220,8 @@ export const PullView: React.FC = ({ // Update to pulling setAgents(prev => prev.map((a, i) => i === index ? { ...a, status: 'pulling' as const } : a)); - // Fetch agent details - const agentDetails = await getAgentApi(client, agent.agentId); + // Fetch agent details (branchId already resolved in initial effect) + const agentDetails = await getAgentApi(client, agent.agentId, branchId); const agentDetailsTyped = agentDetails as { conversationConfig?: Record; conversation_config?: Record; @@ -288,7 +299,7 @@ export const PullView: React.FC = ({ } setCurrentIndex(index + 1); - processNextAgent(agentsList, index + 1, agentsConfig, agentsConfigPath); + processNextAgent(agentsList, index + 1, agentsConfig, agentsConfigPath, branchId); } catch (err) { setAgents(prev => prev.map((a, i) => @@ -299,7 +310,7 @@ export const PullView: React.FC = ({ } : a )); setCurrentIndex(index + 1); - processNextAgent(agentsList, index + 1, agentsConfig, agentsConfigPath); + processNextAgent(agentsList, index + 1, agentsConfig, agentsConfigPath, branchId); } }; diff --git a/src/agents/ui/PushView.tsx b/src/agents/ui/PushView.tsx index 7db653a..6d5e118 100644 --- a/src/agents/ui/PushView.tsx +++ b/src/agents/ui/PushView.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { Box, Text, useApp } from 'ink'; import App from '../../ui/App.js'; import theme from '../../ui/themes/elevenlabs.js'; -import { getElevenLabsClient, createAgentApi, updateAgentApi } from '../../shared/elevenlabs-api.js'; +import { getElevenLabsClient, createAgentApi, updateAgentApi, resolveBranchId } from '../../shared/elevenlabs-api.js'; import { readConfig, writeConfig } from '../../shared/utils.js'; import fs from 'fs-extra'; import path from 'path'; @@ -19,6 +19,7 @@ interface PushViewProps { agents: PushAgent[]; dryRun?: boolean; versionDescription?: string; + branch?: string; onComplete?: () => void; agentsConfigPath?: string; } @@ -27,6 +28,7 @@ export const PushView: React.FC = ({ agents, dryRun = false, versionDescription, + branch, onComplete, agentsConfigPath = 'agents.json' }) => { @@ -98,6 +100,12 @@ export const PushView: React.FC = ({ // Get ElevenLabs client const client = await getElevenLabsClient(); + // Resolve branch if needed + let branchId: string | undefined; + if (branch && agentId) { + branchId = await resolveBranchId(client, agentId, branch); + } + // Extract config components const conversationConfig = agentConfig.conversation_config || {}; const platformSettings = agentConfig.platform_settings; @@ -145,7 +153,8 @@ export const PushView: React.FC = ({ platformSettings, workflow, tags, - versionDescription + versionDescription, + branchId ); // Update version/branch info in agents.json @@ -154,9 +163,34 @@ export const PushView: React.FC = ({ if (agentDef) { if (result.versionId) agentDef.version_id = result.versionId; if (result.branchId) agentDef.branch_id = result.branchId; - await writeConfig(path.resolve(agentsConfigPath), agentsConfig); } + // Auto-push registered branch configs (when no specific --branch is given) + if (!branch && agentDef?.branches) { + for (const [branchName, branchDef] of Object.entries(agentDef.branches) as [string, any][]) { + try { + if (!(await fs.pathExists(branchDef.config))) continue; + const branchConfig = await readConfig(branchDef.config); + const branchResult = await updateAgentApi( + client, + agentId, + branchConfig.name, + branchConfig.conversation_config || {}, + branchConfig.platform_settings, + branchConfig.workflow, + branchConfig.tags || [], + versionDescription, + branchDef.branch_id + ); + if (branchResult.versionId) branchDef.version_id = branchResult.versionId; + } catch { + // Continue pushing other branches even if one fails + } + } + } + + await writeConfig(path.resolve(agentsConfigPath), agentsConfig); + setPushedAgents(prev => prev.map((a, i) => i === currentAgentIndex ? { diff --git a/src/shared/elevenlabs-api.ts b/src/shared/elevenlabs-api.ts index 66ba2c2..7479e9e 100644 --- a/src/shared/elevenlabs-api.ts +++ b/src/shared/elevenlabs-api.ts @@ -151,7 +151,8 @@ export async function updateAgentApi( platformSettingsDict?: Record, workflow?: unknown, tags?: string[], - versionDescription?: string + versionDescription?: string, + branchId?: string ): Promise<{ agentId: string; versionId?: string; branchId?: string }> { // Clean config to remove deprecated 'tools' if 'tool_ids' exists const cleanedConfig = conversationConfigDict ? cleanConversationConfigForApi(conversationConfigDict) : undefined; @@ -167,7 +168,8 @@ export async function updateAgentApi( platformSettings, workflow: workflowConfig, tags, - versionDescription + versionDescription, + ...(branchId ? { branchId } : {}) }); return { @@ -227,12 +229,65 @@ export async function listAgentsApi( * @param agentId - The ID of the agent to retrieve * @returns Promise that resolves to an object containing the full agent configuration */ -export async function getAgentApi(client: ElevenLabsClient, agentId: string): Promise { - const response = await client.conversationalAi.agents.get(agentId); +export async function getAgentApi(client: ElevenLabsClient, agentId: string, branchId?: string): Promise { + const response = branchId + ? await client.conversationalAi.agents.get(agentId, { branchId }) + : await client.conversationalAi.agents.get(agentId); // Normalize response to snake_case for downstream writing return toSnakeCaseKeys(response); } +/** + * Lists branches for a specific agent from the ElevenLabs API. + * + * @param client - An initialized ElevenLabs client + * @param agentId - The ID of the agent + * @param includeArchived - Whether to include archived branches (default: false) + * @returns Promise that resolves to a list of branch summary objects + */ +export async function listBranchesApi( + client: ElevenLabsClient, + agentId: string, + includeArchived: boolean = false +): Promise { + const response = await client.conversationalAi.agents.branches.list(agentId, { + includeArchived + }); + return response.results; +} + +/** + * Resolves a branch name or ID to a branch ID. + * If the input starts with 'agtbrch_', it's treated as an ID directly. + * Otherwise, it's treated as a branch name and resolved via the branches list. + * + * @param client - An initialized ElevenLabs client + * @param agentId - The ID of the agent + * @param branchNameOrId - Branch name or ID to resolve + * @returns Promise that resolves to the branch ID + */ +export async function resolveBranchId( + client: ElevenLabsClient, + agentId: string, + branchNameOrId: string +): Promise { + // If it looks like a branch ID, return it directly + if (branchNameOrId.startsWith('agtbrch_')) { + return branchNameOrId; + } + + // Otherwise, resolve name to ID (include archived so resolution doesn't silently fail) + const branches = await listBranchesApi(client, agentId, true); + const match = branches.find(b => b.name === branchNameOrId); + if (!match) { + throw new Error( + `Branch '${branchNameOrId}' not found for agent '${agentId}'. ` + + `Use 'elevenlabs agents branches list --agent ${agentId}' to see available branches.` + ); + } + return match.id; +} + /** * Deletes an agent using the ElevenLabs API. *