From b93e0d586d898f5ceebaae1645b8bf22302e9604 Mon Sep 17 00:00:00 2001 From: chenanran555 Date: Tue, 25 Aug 2026 20:41:47 +0800 Subject: [PATCH] feat(managed-agent): add Workbench and Git-backed CI versioning Add Workbench launch and local Git version commands, enable automatic agents.yaml versioning after successful applies, introduce CI-safe apply policies, and reuse the shared @openagentpack/local-git package. --- packages/cli/src/commands.ts | 16 + packages/commands/package.json | 3 +- .../managed-agent/_engine/git-project.ts | 505 ++++++++++++++++++ .../_engine/playground-launcher.ts | 375 +++++++++++++ .../src/commands/managed-agent/apply.ts | 114 +++- .../src/commands/managed-agent/init.ts | 62 ++- .../src/commands/managed-agent/version.ts | 359 +++++++++++++ .../src/commands/managed-agent/workbench.ts | 126 +++++ packages/commands/src/index.ts | 12 + .../tests/e2e/managed-agent.e2e.test.ts | 62 +++ packages/commands/tests/e2e/topic-routes.ts | 8 + .../tests/managed-agent-git-project.test.ts | 67 +++ .../tests/managed-agent-local-git.test.ts | 202 +++++++ skills/bailian-managed-agent/SKILL.md | 23 +- .../bailian-managed-agent/reference/index.md | 52 +- .../reference/managed-agent.md | 319 +++++++++-- 16 files changed, 2237 insertions(+), 68 deletions(-) create mode 100644 packages/commands/src/commands/managed-agent/_engine/git-project.ts create mode 100644 packages/commands/src/commands/managed-agent/_engine/playground-launcher.ts create mode 100644 packages/commands/src/commands/managed-agent/version.ts create mode 100644 packages/commands/src/commands/managed-agent/workbench.ts create mode 100644 packages/commands/tests/managed-agent-git-project.test.ts create mode 100644 packages/commands/tests/managed-agent-local-git.test.ts diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 2742293..e52ce30 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -139,6 +139,14 @@ import { managedAgentPlan, managedAgentApply, managedAgentDestroy, + managedAgentWorkbench, + managedAgentPlayground, + managedAgentVersionEnable, + managedAgentVersionDisable, + managedAgentVersionStatus, + managedAgentVersionList, + managedAgentVersionPreview, + managedAgentVersionRestore, managedAgentStateList, managedAgentStateShow, managedAgentStateRm, @@ -300,6 +308,14 @@ export const commands: Record = { "managed-agent plan": managedAgentPlan, "managed-agent apply": managedAgentApply, "managed-agent destroy": managedAgentDestroy, + "managed-agent workbench": managedAgentWorkbench, + "managed-agent playground": managedAgentPlayground, + "managed-agent version enable": managedAgentVersionEnable, + "managed-agent version disable": managedAgentVersionDisable, + "managed-agent version status": managedAgentVersionStatus, + "managed-agent version list": managedAgentVersionList, + "managed-agent version preview": managedAgentVersionPreview, + "managed-agent version restore": managedAgentVersionRestore, "managed-agent state list": managedAgentStateList, "managed-agent state show": managedAgentStateShow, "managed-agent state rm": managedAgentStateRm, diff --git a/packages/commands/package.json b/packages/commands/package.json index a1943e1..d6ab50a 100644 --- a/packages/commands/package.json +++ b/packages/commands/package.json @@ -40,7 +40,8 @@ "check": "vp check" }, "dependencies": { - "@openagentpack/sdk": "0.3.2", + "@openagentpack/local-git": "0.4.0", + "@openagentpack/sdk": "0.4.0", "bailian-cli-core": "workspace:*", "bailian-cli-runtime": "workspace:*", "boxen": "catalog:", diff --git a/packages/commands/src/commands/managed-agent/_engine/git-project.ts b/packages/commands/src/commands/managed-agent/_engine/git-project.ts new file mode 100644 index 0000000..eb96b5f --- /dev/null +++ b/packages/commands/src/commands/managed-agent/_engine/git-project.ts @@ -0,0 +1,505 @@ +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; +import { basename, resolve } from "node:path"; +import { promisify } from "node:util"; +import { BailianError, ExitCode } from "bailian-cli-core"; + +const execFileAsync = promisify(execFile); + +const REPOSITORY_GITIGNORE = `# Dependencies +node_modules/ + +# Bailian CLI local runs +.openagentpack/state/ +.openagentpack/runs/ + +# Local credentials +.env +.env.* +!.env.example +`; + +const REPOSITORY_GITIGNORE_PATTERNS = [ + "node_modules/", + ".openagentpack/state/", + ".openagentpack/runs/", + ".env", + ".env.*", + "!.env.example", +] as const; + +const PROJECT_SCRIPTS = { + "agents:validate": "bl managed-agent validate --file agents.yaml", + "agents:plan": "bl managed-agent plan --file agents.yaml", + "agents:plan:ci": "bl managed-agent plan --file agents.yaml --output json", + "agents:apply:ci": "bl managed-agent apply --file agents.yaml --ci", + "agents:workbench": "bl managed-agent workbench --file agents.yaml", +} as const; + +const INITIAL_STATE = `${JSON.stringify({ resources: [] }, null, 2)}\n`; + +export interface GitProjectResult { + targetDirectory: string; + mode: "created" | "upgraded"; + initializedGit: boolean; + createdFiles: string[]; + updatedFiles: string[]; + preservedFiles: string[]; +} + +interface CreateGitProjectOptions { + config: string; + cliVersion: string; +} + +type ProjectTargetMode = "new" | "existing"; + +export async function inspectGitProjectTarget(targetDirectory: string): Promise { + if (!existsSync(targetDirectory)) return "new"; + const targetStat = await stat(targetDirectory); + if (!targetStat.isDirectory()) { + throw new BailianError(`Target '${targetDirectory}' is not a directory.`, ExitCode.USAGE); + } + const entries = await readdir(targetDirectory); + if (entries.length === 0) return "new"; + if (existsSync(resolve(targetDirectory, "agents.yaml"))) return "existing"; + throw new BailianError( + `Target directory '${targetDirectory}' is not empty and does not contain agents.yaml.`, + ExitCode.USAGE, + ); +} + +export async function createGitProject( + directory: string, + options: CreateGitProjectOptions, +): Promise { + const targetDirectory = resolve(directory); + const targetMode = await inspectGitProjectTarget(targetDirectory); + const shouldInitializeGit = !existsSync(resolve(targetDirectory, ".git")); + if (shouldInitializeGit) await assertGitAvailable(); + + const createdFiles: string[] = []; + const updatedFiles: string[] = []; + const preservedFiles: string[] = []; + await mkdir(resolve(targetDirectory, ".aoneci"), { recursive: true }); + + const config = + targetMode === "new" + ? options.config + : await readFile(resolve(targetDirectory, "agents.yaml"), "utf8"); + if (targetMode === "new") { + await writeFile(resolve(targetDirectory, "agents.yaml"), config, "utf8"); + createdFiles.push("agents.yaml"); + } else { + preservedFiles.push("agents.yaml"); + } + + await mergeOrCreateTextFile( + resolve(targetDirectory, ".gitignore"), + REPOSITORY_GITIGNORE, + mergeGitignore, + ".gitignore", + createdFiles, + updatedFiles, + ); + await mergeOrCreateTextFile( + resolve(targetDirectory, ".env.example"), + environmentExample(config), + (current) => mergeEnvironmentExample(current, config), + ".env.example", + createdFiles, + updatedFiles, + ); + + const packagePath = resolve(targetDirectory, "package.json"); + if (existsSync(packagePath)) { + const current = await readFile(packagePath, "utf8"); + const merged = mergePackageJson(current, basename(targetDirectory), options.cliVersion); + if (merged.content !== current) { + await writeFile(packagePath, merged.content, "utf8"); + updatedFiles.push("package.json"); + } + preservedFiles.push(...merged.preservedSettings); + } else { + await writeFile( + packagePath, + buildPackageJson(basename(targetDirectory), options.cliVersion), + "utf8", + ); + createdFiles.push("package.json"); + } + + await createIfMissing( + resolve(targetDirectory, "agents.state.json"), + INITIAL_STATE, + "agents.state.json", + createdFiles, + preservedFiles, + ); + await createIfMissing( + resolve(targetDirectory, ".aoneci/bailian-cli.yml"), + buildAoneWorkflow(config), + ".aoneci/bailian-cli.yml", + createdFiles, + preservedFiles, + ); + await createIfMissing( + resolve(targetDirectory, ".aoneci/bailian-cli-check.yml"), + buildAoneCheckWorkflow(config), + ".aoneci/bailian-cli-check.yml", + createdFiles, + preservedFiles, + ); + await createIfMissing( + resolve(targetDirectory, "README.md"), + buildReadme(basename(targetDirectory), config), + "README.md", + createdFiles, + preservedFiles, + ); + + if (shouldInitializeGit) await initializeGitRepository(targetDirectory); + return { + targetDirectory, + mode: targetMode === "new" ? "created" : "upgraded", + initializedGit: shouldInitializeGit, + createdFiles, + updatedFiles, + preservedFiles, + }; +} + +async function assertGitAvailable(): Promise { + try { + await execFileAsync("git", ["--version"]); + } catch { + throw new BailianError( + "Git is required to initialize a repository.", + ExitCode.USAGE, + "Install Git and retry.", + ); + } +} + +async function initializeGitRepository(targetDirectory: string): Promise { + try { + await execFileAsync("git", ["init", "--initial-branch", "main"], { + cwd: targetDirectory, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new BailianError( + `Could not initialize the local Git repository: ${message}`, + ExitCode.GENERAL, + ); + } +} + +async function mergeOrCreateTextFile( + path: string, + initialContent: string, + merge: (current: string) => string, + label: string, + createdFiles: string[], + updatedFiles: string[], +): Promise { + if (!existsSync(path)) { + await writeFile(path, initialContent, "utf8"); + createdFiles.push(label); + return; + } + const current = await readFile(path, "utf8"); + const next = merge(current); + if (next !== current) { + await writeFile(path, next, "utf8"); + updatedFiles.push(label); + } +} + +async function createIfMissing( + path: string, + content: string, + label: string, + createdFiles: string[], + preservedFiles: string[], +): Promise { + if (existsSync(path)) { + preservedFiles.push(label); + return; + } + await writeFile(path, content, "utf8"); + createdFiles.push(label); +} + +function mergeGitignore(content: string): string { + const repositoryContent = content + .split(/\r?\n/) + .filter((line) => line.trim() !== "agents.state.json") + .join("\n"); + const existingPatterns = new Set( + repositoryContent + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean), + ); + const missingPatterns = REPOSITORY_GITIGNORE_PATTERNS.filter( + (pattern) => !existingPatterns.has(pattern), + ); + if (missingPatterns.length === 0) return repositoryContent; + return appendBlock( + repositoryContent, + `# Bailian CLI local files\n${missingPatterns.join("\n")}\n`, + ); +} + +function environmentExample(config: string): string { + return `${extractEnvironmentVariables(config) + .map((variable) => `${variable}=replace-me`) + .join("\n")}\n`; +} + +function mergeEnvironmentExample(content: string, config: string): string { + const existingVariables = new Set(); + for (const line of content.split(/\r?\n/)) { + const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/); + if (match?.[1]) existingVariables.add(match[1]); + } + const missingVariables = extractEnvironmentVariables(config).filter( + (variable) => !existingVariables.has(variable), + ); + if (missingVariables.length === 0) return content; + return appendBlock( + content, + `${missingVariables.map((variable) => `${variable}=replace-me`).join("\n")}\n`, + ); +} + +function extractEnvironmentVariables(config: string): string[] { + const variables = new Set(); + for (const match of config.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}/g)) { + if (match[1]) variables.add(match[1]); + } + return [...variables]; +} + +function appendBlock(content: string, block: string): string { + if (!content) return block; + if (content.endsWith("\n\n")) return `${content}${block}`; + if (content.endsWith("\n")) return `${content}\n${block}`; + return `${content}\n\n${block}`; +} + +function buildPackageJson(projectName: string, cliVersion: string): string { + return `${JSON.stringify( + { + name: npmPackageName(projectName), + private: true, + version: "0.0.0", + type: "module", + scripts: PROJECT_SCRIPTS, + devDependencies: { "bailian-cli": cliVersion }, + }, + null, + 2, + )}\n`; +} + +function mergePackageJson( + content: string, + projectName: string, + cliVersion: string, +): { content: string; preservedSettings: string[] } { + let manifest: unknown; + try { + manifest = JSON.parse(content); + } catch { + throw new BailianError( + "Cannot upgrade package.json because it is not valid JSON.", + ExitCode.USAGE, + ); + } + if (!isRecord(manifest)) { + throw new BailianError( + "Cannot upgrade package.json because its root is not an object.", + ExitCode.USAGE, + ); + } + const preservedSettings: string[] = []; + if (manifest.name === undefined) manifest.name = npmPackageName(projectName); + if (manifest.private === undefined) manifest.private = true; + + const scripts = manifest.scripts === undefined ? {} : manifest.scripts; + if (!isRecord(scripts)) { + throw new BailianError( + "Cannot upgrade package.json because 'scripts' is not an object.", + ExitCode.USAGE, + ); + } + manifest.scripts = scripts; + for (const [name, command] of Object.entries(PROJECT_SCRIPTS)) { + if (scripts[name] === undefined) scripts[name] = command; + else if (scripts[name] !== command) preservedSettings.push(`package.json scripts.${name}`); + } + + const developmentDependencies = + manifest.devDependencies === undefined ? {} : manifest.devDependencies; + if (!isRecord(developmentDependencies)) { + throw new BailianError( + "Cannot upgrade package.json because 'devDependencies' is not an object.", + ExitCode.USAGE, + ); + } + manifest.devDependencies = developmentDependencies; + if (developmentDependencies["bailian-cli"] === undefined) { + developmentDependencies["bailian-cli"] = cliVersion; + } else if (developmentDependencies["bailian-cli"] !== cliVersion) { + preservedSettings.push("package.json bailian-cli version"); + } + return { content: `${JSON.stringify(manifest, null, 2)}\n`, preservedSettings }; +} + +function buildAoneEnvironmentBlock(config: string): string { + const variables = extractEnvironmentVariables(config); + if (variables.length === 0) { + return " # Add provider variables referenced by agents.yaml in Aone Flow."; + } + return variables.map((variable) => ` ${variable}: \${{secrets.${variable}}}`).join("\n"); +} + +function buildAoneWorkflow(config: string): string { + const environmentBlock = buildAoneEnvironmentBlock(config); + return `name: Bailian CLI Managed Agent + +triggers: + push: + branches: + - main + +jobs: + apply: + name: Validate, plan, and apply Agent resources + image: alios-8u + timeout: 30m + steps: + - id: checkout + uses: checkout + - id: setup-env + uses: setup-env + inputs: + node-version: 22 + tnpm-version: 10 + tnpm-cache: true + - id: install + run: npm install --ignore-scripts --no-audit --no-fund + - id: validate-and-plan + envs: +${environmentBlock} + run: | + npm run agents:validate + npm run agents:plan:ci > bailian-cli-plan.json + - id: upload-plan + uses: upload-artifact + inputs: + name: bailian-cli-plan + path: bailian-cli-plan.json + - id: apply-and-persist-state + envs: +${environmentBlock} + run: | + set +e + npm run agents:apply:ci + apply_status=$? + set -e + if ! git diff --quiet -- agents.state.json; then + git config user.name "Bailian CLI CI" + git config user.email "bailian-cli-ci@alibaba-inc.com" + git add -- agents.state.json + git commit -m "chore: update Bailian CLI Agent state [skip ci]" + git push origin HEAD:main + fi + exit "$apply_status" +`; +} + +function buildAoneCheckWorkflow(config: string): string { + const environmentBlock = buildAoneEnvironmentBlock(config); + return `name: Bailian CLI Managed Agent Check + +# Bind this pipeline to Codeup merge-request new/update events in Aone Flow. +jobs: + check: + name: Validate and plan Agent resources + image: alios-8u + timeout: 20m + steps: + - id: checkout + uses: checkout + - id: setup-env + uses: setup-env + inputs: + node-version: 22 + tnpm-version: 10 + tnpm-cache: true + - id: install + run: npm install --ignore-scripts --no-audit --no-fund + - id: validate-and-plan + envs: +${environmentBlock} + run: | + npm run agents:validate + npm run agents:plan:ci > bailian-cli-plan.json + - id: upload-plan + uses: upload-artifact + inputs: + name: bailian-cli-plan + path: bailian-cli-plan.json +`; +} + +function buildReadme(projectName: string, config: string): string { + const variableList = extractEnvironmentVariables(config) + .map((variable) => `- \`${variable}\``) + .join("\n"); + return `# ${projectName} + +This repository declares cloud Agent resources with Bailian CLI. + +## Local Workbench + +1. Copy \`.env.example\` to \`.env\` and replace placeholder credentials. +2. Run \`npm install\`. +3. Run \`npm run agents:workbench\`. + +## Aone CI + +\`.aoneci/bailian-cli-check.yml\` validates and plans merge requests without applying. \`.aoneci/bailian-cli.yml\` applies non-destructive local changes after a push to \`main\` and commits the resulting \`agents.state.json\` back to \`main\`. + +Configure these values as secret variables in Aone Flow: + +${variableList || "- Add the provider variables referenced by agents.yaml."} + +Set pipeline concurrency to 1, protect the main branch, and require approval where appropriate. Workbench and CI should use isolated credentials, resource namespaces, and State scopes. + +Create the remote repository yourself, then push this local repository: + +\`\`\`bash +git add . +git commit -m "Initialize Bailian CLI Agent project" +git remote add origin +git push -u origin main +\`\`\` +`; +} + +function npmPackageName(projectName: string): string { + const normalized = projectName + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^[._-]+|[._-]+$/g, ""); + return normalized || "bailian-agent-project"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/commands/src/commands/managed-agent/_engine/playground-launcher.ts b/packages/commands/src/commands/managed-agent/_engine/playground-launcher.ts new file mode 100644 index 0000000..37198a3 --- /dev/null +++ b/packages/commands/src/commands/managed-agent/_engine/playground-launcher.ts @@ -0,0 +1,375 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, resolve } from "node:path"; +import { BailianError, type Client, ExitCode, type Settings } from "bailian-cli-core"; +import { emitBare } from "bailian-cli-runtime"; + +const PLAYGROUND_PACKAGE = "@openagentpack/playground"; +const DEFAULT_PORT = 4848; +const PLAYGROUND_URL_PATTERN = /running at http:\/\/localhost:(\d+)/i; + +export interface PlaygroundLaunchOptions { + port?: number; + open: boolean; + file: string; + agent?: string; + surface: "preview" | "workbench"; + client: Client; + settings: Settings; +} + +interface Launcher { + command: string; + args: string[]; + version?: string; + fetched: boolean; +} + +interface ExistingPlayground { + version: string; + pid: number; + projectId?: string; +} + +interface PlaygroundProjectSummary { + status?: string; + agents?: Array<{ agent?: { id?: string } }>; +} + +export interface PlaygroundBrowserTarget { + url: string; + warning?: string; +} + +export async function launchManagedAgentPlayground( + options: PlaygroundLaunchOptions, +): Promise { + assertSupportedNodeVersion(); + const port = options.port ?? DEFAULT_PORT; + if (!Number.isInteger(port) || port <= 0 || port > 65_535) { + throw new BailianError(`Invalid --port '${port}'.`, ExitCode.USAGE); + } + const configPath = resolve(options.file); + const projectId = createHash("sha256").update(configPath).digest("hex").slice(0, 16); + const launcher = resolveLauncher(); + const existing = await probeExistingPlayground(port); + if (existing) { + const reusable = + existing.projectId === projectId && + (launcher.version === undefined || existing.version === launcher.version); + if (reusable) { + emitBare(`Workbench already running at http://localhost:${port} (pid ${existing.pid}).`); + await openPlaygroundSurface(port, options.surface, options.agent, options.open); + return; + } + const released = await replaceExistingPlayground(existing, port); + if (!released) { + throw new BailianError( + `Could not stop the existing Workbench process (pid ${existing.pid}) on port ${port}.`, + ExitCode.GENERAL, + "Stop it manually or choose another --port.", + ); + } + } + + const environment = buildPlaygroundEnvironment(options, port, configPath); + if (launcher.fetched) { + emitBare(`Fetching ${PLAYGROUND_PACKAGE} (first run may take a moment)...`); + } + const child = spawn(launcher.command, launcher.args, { + env: environment, + stdio: ["inherit", "pipe", "inherit"], + }); + const removeSignalForwarding = forwardSignals(child); + try { + const readyPort = await waitForPlaygroundReady(child, port, 30_000, projectId); + if (readyPort === null) { + throw new BailianError( + `Workbench did not become ready in time. Check the logs above, then open http://localhost:${port}.`, + ExitCode.GENERAL, + ); + } + emitBare(`Workbench ready at http://localhost:${readyPort}`); + await openPlaygroundSurface(readyPort, options.surface, options.agent, options.open); + const exitCode = await waitForChildExit(child); + if (exitCode !== 0) { + throw new BailianError(`Workbench exited with code ${exitCode}.`, ExitCode.GENERAL); + } + } finally { + removeSignalForwarding(); + } +} + +export function playgroundBrowserTargetFromSummary( + baseUrl: string, + summary: PlaygroundProjectSummary, + requestedAgent?: string, +): PlaygroundBrowserTarget { + if (summary.status !== "valid") return { url: baseUrl }; + const agentIds = (summary.agents ?? []) + .map((entry) => entry.agent?.id?.trim()) + .filter((agentId): agentId is string => Boolean(agentId)); + const requested = requestedAgent?.trim(); + if (requested) { + if (agentIds.includes(requested)) { + return { url: `${baseUrl}/agents/${encodeURIComponent(requested)}/preview` }; + } + return { + url: baseUrl, + warning: `Agent '${requested}' was not found. Opening the project Workbench instead.`, + }; + } + if (agentIds.length === 1) { + return { url: `${baseUrl}/agents/${encodeURIComponent(agentIds[0]!)}/preview` }; + } + if (agentIds.length > 1) { + return { + url: baseUrl, + warning: + "This project declares multiple Agents. Opening the Workbench; rerun with --agent for Preview.", + }; + } + return { url: baseUrl }; +} + +function assertSupportedNodeVersion(): void { + const majorVersion = Number(process.versions.node.split(".")[0]); + if (Number.isFinite(majorVersion) && majorVersion >= 22) return; + throw new BailianError( + "Managed Agent Workbench requires Node.js 22 or later.", + ExitCode.USAGE, + "Upgrade Node.js for Workbench; other Bailian CLI commands continue to support Node.js 18.17+.", + ); +} + +function resolveLauncher(): Launcher { + const explicit = + process.env.BAILIAN_MANAGED_AGENT_PLAYGROUND_BIN?.trim() || + process.env.AGENTS_PLAYGROUND_BIN?.trim(); + if (explicit) { + if (!existsSync(explicit)) { + throw new BailianError( + `Configured Workbench binary does not exist: ${explicit}`, + ExitCode.USAGE, + ); + } + return { command: process.execPath, args: [explicit], fetched: false }; + } + + const installed = resolveInstalledPlayground(); + if (installed) return installed; + + const monorepoBinary = findLocalPlaygroundBin(process.cwd()); + if (monorepoBinary) { + return { command: process.execPath, args: [monorepoBinary], fetched: false }; + } + + const requestedVersion = process.env.BAILIAN_MANAGED_AGENT_PLAYGROUND_VERSION?.trim() || "latest"; + return { + command: "npx", + args: ["-y", `${PLAYGROUND_PACKAGE}@${requestedVersion}`], + version: requestedVersion === "latest" ? undefined : requestedVersion, + fetched: true, + }; +} + +function resolveInstalledPlayground(): Launcher | undefined { + try { + const require = createRequire(import.meta.url); + const packageJsonPath = require.resolve(`${PLAYGROUND_PACKAGE}/package.json`); + const manifest = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { + version?: string; + bin?: string | Record; + }; + const relativeBinary = + typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.["agents-playground"]; + if (!relativeBinary) return undefined; + const binaryPath = resolve(dirname(packageJsonPath), relativeBinary); + if (!existsSync(binaryPath)) return undefined; + return { + command: process.execPath, + args: [binaryPath], + version: manifest.version, + fetched: false, + }; + } catch { + return undefined; + } +} + +function findLocalPlaygroundBin(startDirectory: string): string | undefined { + let directory = startDirectory; + for (let depth = 0; depth < 10; depth += 1) { + const candidate = resolve(directory, "packages/playground/dist/bin/playground.js"); + if (existsSync(candidate)) return candidate; + const parent = dirname(directory); + if (parent === directory) break; + directory = parent; + } + return undefined; +} + +function buildPlaygroundEnvironment( + options: PlaygroundLaunchOptions, + port: number, + configPath: string, +): NodeJS.ProcessEnv { + const credential = options.client.exportApiCredential(); + const environment: NodeJS.ProcessEnv = { + ...process.env, + PORT: String(port), + AGENTS_CONFIG_PATH: configPath, + AGENTS_PLAYGROUND_TOKEN: randomBytes(32).toString("hex"), + }; + if (credential) environment.DASHSCOPE_API_KEY = credential.token; + const baseUrl = options.client.baseUrl.replace(/\/+$/, ""); + environment.BAILIAN_BASE_URL = baseUrl.endsWith("/api/v1/agentstudio") + ? baseUrl + : `${baseUrl}/api/v1/agentstudio`; + if (options.settings.workspaceId) { + environment.BAILIAN_WORKSPACE_ID = options.settings.workspaceId; + } + return environment; +} + +async function waitForPlaygroundReady( + child: ChildProcess, + fallbackPort: number, + timeoutMs: number, + expectedProjectId: string, +): Promise { + let port = fallbackPort; + let outputBuffer = ""; + child.stdout?.on("data", (chunk: Buffer | string) => { + process.stdout.write(chunk); + outputBuffer += chunk.toString(); + const match = outputBuffer.match(PLAYGROUND_URL_PATTERN); + if (match?.[1]) port = Number(match[1]); + }); + + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + if (child.exitCode !== null) return null; + try { + const response = await fetch(`http://localhost:${port}/health`, { + signal: AbortSignal.timeout(1_000), + }); + const body = response.ok + ? ((await response.json()) as { playground?: { project_id?: string } }) + : undefined; + if (body?.playground?.project_id === expectedProjectId) return port; + } catch { + // Not ready yet. + } + await new Promise((resolveWait) => setTimeout(resolveWait, 300)); + } + return null; +} + +async function probeExistingPlayground(port: number): Promise { + try { + const response = await fetch(`http://localhost:${port}/health`, { + signal: AbortSignal.timeout(2_000), + }); + if (!response.ok) return null; + const body = (await response.json()) as { + playground?: { version?: string; pid?: number; project_id?: string }; + }; + if (!body.playground?.pid) return null; + return { + version: body.playground.version ?? "unknown", + pid: body.playground.pid, + projectId: body.playground.project_id, + }; + } catch { + return null; + } +} + +async function replaceExistingPlayground( + existing: ExistingPlayground, + port: number, +): Promise { + emitBare(`Replacing Workbench v${existing.version} (pid ${existing.pid}) on port ${port}...`); + try { + process.kill(existing.pid, "SIGTERM"); + } catch { + return true; + } + for (let attempt = 0; attempt < 30; attempt += 1) { + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + if (!(await probeExistingPlayground(port))) return true; + } + return false; +} + +async function openPlaygroundSurface( + port: number, + surface: "preview" | "workbench", + requestedAgent: string | undefined, + shouldOpen: boolean, +): Promise { + if (!shouldOpen) return; + const target = + surface === "workbench" + ? { url: `http://localhost:${port}` } + : await resolvePlaygroundBrowserTarget(port, requestedAgent); + if (target.warning) emitBare(`Warning: ${target.warning}`); + openBrowser(target.url); +} + +async function resolvePlaygroundBrowserTarget( + port: number, + requestedAgent?: string, +): Promise { + const baseUrl = `http://localhost:${port}`; + try { + const response = await fetch(`${baseUrl}/api/project`, { + signal: AbortSignal.timeout(3_000), + }); + if (!response.ok) return { url: baseUrl }; + return playgroundBrowserTargetFromSummary( + baseUrl, + (await response.json()) as PlaygroundProjectSummary, + requestedAgent, + ); + } catch { + return { url: baseUrl }; + } +} + +function openBrowser(url: string): void { + const command = + process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; + const args = process.platform === "win32" ? ["", url] : [url]; + try { + spawn(command, args, { + stdio: "ignore", + detached: true, + shell: process.platform === "win32", + }).unref(); + } catch { + emitBare(`Could not open a browser automatically. Visit ${url}`); + } +} + +function forwardSignals(child: ChildProcess): () => void { + const forwardInterrupt = () => child.kill("SIGINT"); + const forwardTerminate = () => child.kill("SIGTERM"); + process.on("SIGINT", forwardInterrupt); + process.on("SIGTERM", forwardTerminate); + return () => { + process.off("SIGINT", forwardInterrupt); + process.off("SIGTERM", forwardTerminate); + }; +} + +function waitForChildExit(child: ChildProcess): Promise { + if (child.exitCode !== null) return Promise.resolve(child.exitCode); + return new Promise((resolveExit, rejectExit) => { + child.once("exit", (exitCode) => resolveExit(exitCode ?? 0)); + child.once("error", rejectExit); + }); +} diff --git a/packages/commands/src/commands/managed-agent/apply.ts b/packages/commands/src/commands/managed-agent/apply.ts index 6040a0a..320ef8a 100644 --- a/packages/commands/src/commands/managed-agent/apply.ts +++ b/packages/commands/src/commands/managed-agent/apply.ts @@ -6,7 +6,12 @@ import { type FlagsDef, } from "bailian-cli-core"; import { emitBare, emitResult } from "bailian-cli-runtime"; -import { executePlannedProject, planProjectContext } from "@openagentpack/sdk"; +import { + executePlannedProject, + planProjectContext, + type PlannedAction, + UserError, +} from "@openagentpack/sdk"; import { formatResourceLabel } from "./_engine/address-utils.ts"; import { assertProviderConfigured, @@ -16,6 +21,12 @@ import { import { withStdoutProtected } from "./_engine/console-capture.ts"; import { withAgentErrors } from "./_engine/errors.ts"; import { renderAgentFeedback } from "./_engine/feedback.ts"; +import { + commitAutomaticVersion, + type PreparedAutomaticVersion, + prepareAutomaticVersion, + readVersionSource, +} from "@openagentpack/local-git"; const APPLY_FLAGS = { file: { @@ -41,6 +52,13 @@ const APPLY_FLAGS = { "zh-CN": "无需交互提示直接确认并应用(执行变更时必填)", }, }, + ci: { + type: "switch", + description: { + "en-US": "Run non-interactively while blocking deletes and remote drift", + "zh-CN": "以非交互模式运行,并阻止删除和远端漂移覆盖", + }, + }, noRefresh: { type: "switch", description: { @@ -48,6 +66,13 @@ const APPLY_FLAGS = { "zh-CN": "规划前跳过从远端刷新状态", }, }, + refreshOnly: { + type: "switch", + description: { + "en-US": "Refresh state without mutating remote resources", + "zh-CN": "仅刷新 State,不修改远端资源", + }, + }, concurrency: { type: "number", valueHint: "", @@ -64,10 +89,18 @@ export default defineCommand({ "zh-CN": "应用规划的变更,创建、更新或删除 Agent 资源", }, auth: "apiKey", - usageArgs: "[--file ] [--provider ] [--yes] [--concurrency ]", + usageArgs: + "[--file ] [--provider ] [--yes | --ci] [--no-refresh] [--refresh-only] [--concurrency ]", flags: APPLY_FLAGS, - exampleArgs: ["--yes", "--provider bailian --yes"], + exampleArgs: ["--yes", "--provider bailian --yes", "--ci"], notes: CREDENTIALS_NOTE, + validate(flags) { + if (flags.ci && flags.yes) return "--ci cannot be combined with --yes."; + if (flags.ci && flags.noRefresh) { + return "--ci requires remote state refresh and cannot be combined with --no-refresh."; + } + return undefined; + }, async run(ctx) { const { settings, flags } = ctx; const format = detectOutputFormat(settings.output); @@ -80,6 +113,8 @@ export default defineCommand({ provider: flags.provider ?? "all", refresh: !flags.noRefresh, concurrency: flags.concurrency, + ci: flags.ci, + refresh_only: flags.refreshOnly, }, config_file: file, hint: "Run `managed-agent plan` to preview the exact resource changes.", @@ -89,16 +124,19 @@ export default defineCommand({ return; } - const planned = await withAgentErrors(() => + const versionSource = await readVersionSource(file); + + const { planned, runtime } = await withAgentErrors(() => withStdoutProtected(async () => { const runtime = await buildAgentRuntime(ctx, file); assertProviderConfigured(runtime, flags.provider); - return planProjectContext(runtime, { + const planned = await planProjectContext(runtime, { provider: flags.provider, refresh: !flags.noRefresh, quiet: true, onFeedback: renderAgentFeedback, }); + return { planned, runtime }; }), ); @@ -118,6 +156,13 @@ export default defineCommand({ const actionable = plan.actions.filter((action) => action.action !== "no-op"); if (actionable.length === 0) { + if (!flags.refreshOnly) { + const preparedVersion = await prepareAutomaticVersion( + runtime.configPath, + versionSource.source, + ); + await commitSuccessfulApplyVersion(preparedVersion, format); + } if (format === "json") emitResult({ succeeded: 0, failed: 0, skipped: 0, results: [] }, format); else emitBare("No changes. Infrastructure is up-to-date."); @@ -127,13 +172,32 @@ export default defineCommand({ const creates = actionable.filter((action) => action.action === "create").length; const updates = actionable.filter((action) => action.action === "update").length; const deletes = planned.destructiveActions; + if (flags.ci) assertCiApplyPolicy(actionable); for (const action of actionable) { const icon = action.action === "create" ? "+" : action.action === "update" ? "~" : "-"; emitProgress(` ${icon} ${formatResourceLabel(action.address)}`); } - if (!flags.yes) { + if (flags.refreshOnly) { + if (format === "json") { + emitResult( + { + refresh_only: true, + actions: actionable, + succeeded: 0, + failed: 0, + skipped: actionable.length, + }, + format, + ); + } else { + emitBare("Refresh-only mode: no remote mutations were performed."); + } + return; + } + + if (!flags.yes && !flags.ci) { throw new BailianError( `Refusing to apply ${actionable.length} change(s) (${creates} create, ${updates} update, ${deletes.length} destroy) without confirmation.`, ExitCode.USAGE, @@ -141,6 +205,8 @@ export default defineCommand({ ); } + const preparedVersion = await prepareAutomaticVersion(runtime.configPath, versionSource.source); + const result = await withAgentErrors(() => withStdoutProtected(() => executePlannedProject(planned, { @@ -161,6 +227,40 @@ export default defineCommand({ emitBare(`\nApply finished: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped.`); } - if (failed > 0) throw new BailianError("Apply failed.", ExitCode.GENERAL); + if (failed > 0 || skipped > 0) { + throw new BailianError( + failed > 0 ? "Apply failed." : "Apply incomplete: one or more actions were skipped.", + ExitCode.GENERAL, + ); + } + await commitSuccessfulApplyVersion(preparedVersion, format); }, }); + +export function assertCiApplyPolicy(actions: PlannedAction[]): void { + const deletes = actions.filter((action) => action.action === "delete"); + if (deletes.length > 0) { + throw new UserError( + `CI policy blocked ${deletes.length} delete action(s). Review the plan and apply this destructive change through an explicitly approved workflow.`, + ); + } + const drifted = actions.filter( + (action) => action.driftKind === "remote" || action.driftKind === "both", + ); + if (drifted.length > 0) { + throw new UserError( + `CI policy blocked ${drifted.length} action(s) with remote drift. Review the remote changes before deciding whether YAML should overwrite them.`, + ); + } +} + +async function commitSuccessfulApplyVersion( + prepared: PreparedAutomaticVersion | null, + format: "text" | "json", +): Promise { + if (!prepared) return; + const version = await commitAutomaticVersion(prepared); + if (version && format !== "json") { + emitBare(`Created local version ${version.short_commit} (${version.message}).`); + } +} diff --git a/packages/commands/src/commands/managed-agent/init.ts b/packages/commands/src/commands/managed-agent/init.ts index ac3c83c..f2fc8ee 100644 --- a/packages/commands/src/commands/managed-agent/init.ts +++ b/packages/commands/src/commands/managed-agent/init.ts @@ -8,6 +8,7 @@ import { type FlagsDef, } from "bailian-cli-core"; import { emitBare, emitResult } from "bailian-cli-runtime"; +import { createGitProject, inspectGitProjectTarget } from "./_engine/git-project.ts"; const GITIGNORE_ADDITIONS = ` # agents @@ -100,6 +101,14 @@ const INIT_FLAGS = { "zh-CN": "输出配置路径(默认:agents.yaml)", }, }, + git: { + type: "string", + valueHint: "", + description: { + "en-US": "Create or add CI/Git scaffolding in this project directory", + "zh-CN": "在此项目目录中创建或补充 CI/Git 脚手架", + }, + }, force: { type: "switch", description: { "en-US": "Overwrite an existing config file", "zh-CN": "覆盖已有配置文件" }, @@ -108,13 +117,19 @@ const INIT_FLAGS = { export default defineCommand({ description: { - "en-US": "Create a new agents.yaml template", - "zh-CN": "创建新的 agents.yaml 模板", + "en-US": "Create an agents.yaml template or a local CI/Git project", + "zh-CN": "创建 agents.yaml 模板或本地 CI/Git 项目", }, auth: "none", - usageArgs: "[--provider ] [--agent-name ] [--file ] [--force]", + usageArgs: + "[--provider ] [--agent-name ] [--file ] [--git ] [--force]", flags: INIT_FLAGS, - exampleArgs: ["", "--provider bailian --agent-name assistant", "--provider all"], + exampleArgs: ["", "--provider bailian --agent-name assistant", "--git ./my-agents", "--git ."], + validate(flags) { + if (flags.git && flags.file) return "--git cannot be combined with --file."; + if (flags.git && flags.force) return "--git cannot be combined with --force."; + return undefined; + }, async run(ctx) { const { settings, flags } = ctx; const format = detectOutputFormat(settings.output); @@ -122,6 +137,45 @@ export default defineCommand({ const agentName = flags.agentName ?? "assistant"; const file = flags.file ?? "agents.yaml"; + if (flags.git) { + const targetMode = await inspectGitProjectTarget(flags.git); + if (settings.dryRun) { + emitResult( + { + would_initialize_git_project: flags.git, + mode: targetMode === "new" ? "create" : "upgrade", + provider, + agent: agentName, + }, + format, + ); + return; + } + const template = buildTemplate({ provider, agentName }); + const result = await createGitProject(flags.git, { + config: template, + cliVersion: ctx.identity.version, + }); + if (format === "json") { + emitResult(result, format); + } else { + const action = + result.mode === "created" ? "Created CI/Git project" : "Added CI/Git scaffolding"; + emitBare(`${action} at ${result.targetDirectory}`); + if (result.createdFiles.length > 0) { + emitBare(`Created: ${result.createdFiles.join(", ")}`); + } + if (result.updatedFiles.length > 0) { + emitBare(`Updated: ${result.updatedFiles.join(", ")}`); + } + if (result.preservedFiles.length > 0) { + emitBare(`Preserved: ${result.preservedFiles.join(", ")}`); + } + emitBare("Next: add credentials to .env, install dependencies, and open the Workbench."); + } + return; + } + if (existsSync(file) && !flags.force) { throw new BailianError( `${file} already exists.`, diff --git a/packages/commands/src/commands/managed-agent/version.ts b/packages/commands/src/commands/managed-agent/version.ts new file mode 100644 index 0000000..e9caefc --- /dev/null +++ b/packages/commands/src/commands/managed-agent/version.ts @@ -0,0 +1,359 @@ +import { + BailianError, + defineCommand, + detectOutputFormat, + ExitCode, + type FlagsDef, +} from "bailian-cli-core"; +import { confirmDangerousAction, emitBare, emitResult } from "bailian-cli-runtime"; +import chalk from "chalk"; +import { + disableLocalVersioning, + enableLocalVersioning, + getLocalVersionStatus, + type LocalProjectVersion, + type LocalVersionPreview, + type LocalVersionStatus, + listLocalVersions, + previewLocalVersion, + restoreLocalVersion, +} from "@openagentpack/local-git"; + +const FILE_FLAG = { + file: { + type: "string", + valueHint: "", + description: { + "en-US": "Config file path (default: agents.yaml)", + "zh-CN": "配置文件路径(默认:agents.yaml)", + }, + }, +} satisfies FlagsDef; + +const COMMIT_FLAG = { + commit: { + type: "string", + valueHint: "", + required: true, + description: { + "en-US": "Full commit SHA from the current branch", + "zh-CN": "当前分支中的完整 Commit SHA", + }, + }, +} satisfies FlagsDef; + +export const managedAgentVersionEnable = defineCommand({ + description: { + "en-US": "Enable Apply-time Git versioning for agents.yaml", + "zh-CN": "为 agents.yaml 启用 Apply 后自动 Git 版本管理", + }, + auth: "none", + usageArgs: "[--file ]", + flags: FILE_FLAG, + exampleArgs: ["", "--file agents.yaml"], + async run(ctx) { + const file = ctx.flags.file ?? "agents.yaml"; + const format = detectOutputFormat(ctx.settings.output); + if (ctx.settings.dryRun) { + emitResult({ would_enable: file, git: await getLocalVersionStatus(file) }, format); + return; + } + const result = await enableLocalVersioning(file, "Enable Bailian CLI versioning"); + if (format === "json") { + emitResult(result, format); + return; + } + if (result.version) { + emitBare(`Created baseline version ${result.version.short_commit} ${result.version.message}`); + } else { + emitBare("Current agents.yaml is already versioned; no commit was created."); + } + emitBare("Automatic versioning is enabled for this agents.yaml."); + renderStatus(result.git); + }, +}); + +export const managedAgentVersionDisable = defineCommand({ + description: { + "en-US": "Disable Apply-time Git versioning without removing history", + "zh-CN": "关闭 Apply 后自动 Git 版本管理,但保留历史", + }, + auth: "none", + usageArgs: "[--file ]", + flags: FILE_FLAG, + exampleArgs: ["", "--file agents.yaml"], + async run(ctx) { + const file = ctx.flags.file ?? "agents.yaml"; + const format = detectOutputFormat(ctx.settings.output); + if (ctx.settings.dryRun) { + emitResult({ would_disable: file, git: await getLocalVersionStatus(file) }, format); + return; + } + const status = await disableLocalVersioning(file); + if (format === "json") { + emitResult(status, format); + return; + } + emitBare("Automatic versioning is disabled for this agents.yaml."); + renderStatus(status); + }, +}); + +export const managedAgentVersionStatus = defineCommand({ + description: { + "en-US": "Show local Git versioning status for agents.yaml", + "zh-CN": "显示 agents.yaml 的本地 Git 版本管理状态", + }, + auth: "none", + usageArgs: "[--file ]", + flags: FILE_FLAG, + exampleArgs: ["", "--file agents.yaml --output json"], + async run(ctx) { + const status = await getLocalVersionStatus(ctx.flags.file ?? "agents.yaml"); + const format = detectOutputFormat(ctx.settings.output); + if (format === "json") emitResult(status, format); + else renderStatus(status); + }, +}); + +const LIST_FLAGS = { + ...FILE_FLAG, + limit: { + type: "number", + valueHint: "", + description: { + "en-US": "Maximum versions to return (default: 50, max: 100)", + "zh-CN": "最多返回的版本数(默认:50,最大:100)", + }, + }, + cursor: { + type: "string", + valueHint: "", + description: { + "en-US": "Pagination cursor returned by the previous page", + "zh-CN": "上一页返回的分页游标", + }, + }, +} satisfies FlagsDef; + +export const managedAgentVersionList = defineCommand({ + description: { + "en-US": "List current-branch commits that changed agents.yaml", + "zh-CN": "列出当前分支中修改过 agents.yaml 的 Commit", + }, + auth: "none", + usageArgs: "[--file ] [--limit ] [--cursor ]", + flags: LIST_FLAGS, + exampleArgs: ["", "--limit 20 --output json"], + async run(ctx) { + const page = await listLocalVersions(ctx.flags.file ?? "agents.yaml", { + limit: ctx.flags.limit, + cursor: ctx.flags.cursor, + }); + const format = detectOutputFormat(ctx.settings.output); + if (format === "json") { + emitResult(page, format); + return; + } + if (page.versions.length === 0) { + emitBare("No versions of agents.yaml exist on the current branch."); + return; + } + for (const version of page.versions) emitBare(formatVersion(version)); + if (page.next_cursor) emitBare(chalk.dim(`Next cursor: ${page.next_cursor}`)); + }, +}); + +const PREVIEW_FLAGS = { + ...FILE_FLAG, + ...COMMIT_FLAG, +} satisfies FlagsDef; + +export const managedAgentVersionPreview = defineCommand({ + description: { + "en-US": "Preview a historical agents.yaml version", + "zh-CN": "预览 agents.yaml 的历史版本", + }, + auth: "none", + usageArgs: "--commit [--file ]", + flags: PREVIEW_FLAGS, + exampleArgs: ["--commit ", "--commit --output json"], + async run(ctx) { + const preview = await previewLocalVersion(ctx.flags.file ?? "agents.yaml", ctx.flags.commit); + const format = detectOutputFormat(ctx.settings.output); + if (format === "json") emitResult(preview, format); + else renderPreview(preview); + }, +}); + +const RESTORE_FLAGS = { + ...PREVIEW_FLAGS, + yes: { + type: "switch", + description: { + "en-US": "Restore without an interactive confirmation", + "zh-CN": "无需交互确认直接恢复", + }, + }, +} satisfies FlagsDef; + +export const managedAgentVersionRestore = defineCommand({ + description: { + "en-US": "Restore a historical agents.yaml version to the working tree", + "zh-CN": "将 agents.yaml 历史版本恢复到工作区", + }, + auth: "none", + usageArgs: "--commit [--file ] [--yes]", + flags: RESTORE_FLAGS, + exampleArgs: ["--commit ", "--commit --yes --output json"], + async run(ctx) { + const file = ctx.flags.file ?? "agents.yaml"; + const preview = await previewLocalVersion(file, ctx.flags.commit); + const format = detectOutputFormat(ctx.settings.output); + if (format !== "json") renderPreview(preview); + if (!preview.can_restore) { + throw new BailianError( + preview.diagnostics.find((diagnostic) => diagnostic.severity === "error")?.message ?? + preview.blockers[0] ?? + "This version cannot be restored.", + ExitCode.GENERAL, + ); + } + if (ctx.settings.dryRun) { + emitResult({ would_restore: ctx.flags.commit, preview }, format); + return; + } + await confirmDangerousAction( + "Restore this version to the agents.yaml working tree? HEAD and agents.state.json will not change.", + ctx.flags.yes, + ); + const restored = await restoreLocalVersion(file, ctx.flags.commit, { + head: preview.base_head, + sourceRevision: preview.base_source_revision, + }); + if (format === "json") { + emitResult({ restored: ctx.flags.commit, preview: restored }, format); + } else { + emitBare( + `Restored ${ctx.flags.commit.slice(0, 12)} to the working tree. HEAD was not changed.`, + ); + } + }, +}); + +function renderStatus(status: LocalVersionStatus): void { + emitBare(`Git available: ${status.git_available ? "yes" : "no"}`); + emitBare(`Automatic versioning: ${status.enabled ? "enabled" : "disabled"}`); + emitBare(`Repository: ${status.repository_root ?? "none"}`); + emitBare(`Config path: ${status.config_path ?? "none"}`); + emitBare(`Branch: ${status.branch ?? "none"}`); + emitBare(`HEAD: ${status.head ?? "none"}`); + emitBare( + `agents.yaml: ${status.config_status}${status.config_versioned ? ", versioned" : ", unversioned"}`, + ); + const blockers = [...new Set([...status.commit_blockers, ...status.restore_blockers])]; + for (const blocker of blockers) emitBare(chalk.yellow(`Blocker: ${blocker}`)); +} + +function formatVersion(version: LocalProjectVersion): string { + return `${chalk.yellow(version.short_commit)} ${version.authored_at} ${version.message} ${chalk.dim(`(${version.author_name})`)}`; +} + +function renderPreview(preview: LocalVersionPreview): void { + emitBare(chalk.bold(`Version ${preview.commit}`)); + emitBare(chalk.red("--- working tree")); + emitBare(chalk.green(`+++ ${preview.commit}`)); + for (const line of buildLineDiff(preview.before_yaml, preview.after_yaml)) { + if (line.kind === "deletion") emitBare(chalk.red(`-${line.text}`)); + else if (line.kind === "addition") emitBare(chalk.green(`+${line.text}`)); + else emitBare(chalk.dim(` ${line.text}`)); + } + for (const diagnostic of preview.diagnostics) { + const color = + diagnostic.severity === "error" + ? chalk.red + : diagnostic.severity === "warning" + ? chalk.yellow + : chalk.dim; + emitBare(color(`${diagnostic.severity}: ${diagnostic.code}: ${diagnostic.message}`)); + } + for (const blocker of preview.blockers) emitBare(chalk.yellow(`blocker: ${blocker}`)); + emitBare(`Can restore: ${preview.can_restore ? "yes" : "no"}`); +} + +type DiffLine = { kind: "context" | "addition" | "deletion"; text: string }; + +function buildLineDiff(beforeSource: string, afterSource: string): DiffLine[] { + const beforeLines = yamlLines(beforeSource); + const afterLines = yamlLines(afterSource); + const maximumDistance = beforeLines.length + afterLines.length; + const frontier = new Map([[1, 0]]); + const traces: Array> = []; + + for (let editDistance = 0; editDistance <= maximumDistance; editDistance += 1) { + traces.push(new Map(frontier)); + for (let diagonal = -editDistance; diagonal <= editDistance; diagonal += 2) { + const deletionStart = frontier.get(diagonal - 1) ?? Number.NEGATIVE_INFINITY; + const additionStart = frontier.get(diagonal + 1) ?? Number.NEGATIVE_INFINITY; + const startsWithAddition = + diagonal === -editDistance || (diagonal !== editDistance && deletionStart < additionStart); + let beforeIndex = startsWithAddition ? (frontier.get(diagonal + 1) ?? 0) : deletionStart + 1; + let afterIndex = beforeIndex - diagonal; + while ( + beforeIndex < beforeLines.length && + afterIndex < afterLines.length && + beforeLines[beforeIndex] === afterLines[afterIndex] + ) { + beforeIndex += 1; + afterIndex += 1; + } + frontier.set(diagonal, beforeIndex); + if (beforeIndex >= beforeLines.length && afterIndex >= afterLines.length) { + return backtrackDiff(beforeLines, afterLines, traces, editDistance); + } + } + } + return []; +} + +function backtrackDiff( + beforeLines: string[], + afterLines: string[], + traces: Array>, + finalDistance: number, +): DiffLine[] { + let beforeIndex = beforeLines.length; + let afterIndex = afterLines.length; + const reversedLines: DiffLine[] = []; + for (let editDistance = finalDistance; editDistance >= 0; editDistance -= 1) { + const frontier = traces[editDistance]!; + const diagonal = beforeIndex - afterIndex; + const deletionStart = frontier.get(diagonal - 1) ?? Number.NEGATIVE_INFINITY; + const additionStart = frontier.get(diagonal + 1) ?? Number.NEGATIVE_INFINITY; + const cameFromAddition = + diagonal === -editDistance || (diagonal !== editDistance && deletionStart < additionStart); + const previousDiagonal = cameFromAddition ? diagonal + 1 : diagonal - 1; + const previousBeforeIndex = frontier.get(previousDiagonal) ?? 0; + const previousAfterIndex = previousBeforeIndex - previousDiagonal; + while (beforeIndex > previousBeforeIndex && afterIndex > previousAfterIndex) { + reversedLines.push({ kind: "context", text: beforeLines[beforeIndex - 1]! }); + beforeIndex -= 1; + afterIndex -= 1; + } + if (editDistance === 0) break; + if (beforeIndex === previousBeforeIndex) { + reversedLines.push({ kind: "addition", text: afterLines[afterIndex - 1]! }); + afterIndex -= 1; + } else { + reversedLines.push({ kind: "deletion", text: beforeLines[beforeIndex - 1]! }); + beforeIndex -= 1; + } + } + return reversedLines.reverse(); +} + +function yamlLines(source: string): string[] { + const lines = source.split("\n"); + if (lines[lines.length - 1] === "") lines.pop(); + return lines; +} diff --git a/packages/commands/src/commands/managed-agent/workbench.ts b/packages/commands/src/commands/managed-agent/workbench.ts new file mode 100644 index 0000000..059925c --- /dev/null +++ b/packages/commands/src/commands/managed-agent/workbench.ts @@ -0,0 +1,126 @@ +import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core"; +import { emitResult } from "bailian-cli-runtime"; +import { CREDENTIALS_NOTE } from "./_engine/config-loader.ts"; +import { launchManagedAgentPlayground } from "./_engine/playground-launcher.ts"; + +const WORKBENCH_FLAGS = { + file: { + type: "string", + valueHint: "", + description: { + "en-US": "Config file path (default: agents.yaml)", + "zh-CN": "配置文件路径(默认:agents.yaml)", + }, + }, + port: { + type: "number", + valueHint: "", + description: { + "en-US": "Local port (default: 4848)", + "zh-CN": "本地端口(默认:4848)", + }, + }, + noOpen: { + type: "switch", + description: { + "en-US": "Do not open a browser automatically", + "zh-CN": "不自动打开浏览器", + }, + }, +} satisfies FlagsDef; + +const PLAYGROUND_FLAGS = { + ...WORKBENCH_FLAGS, + agent: { + type: "string", + valueHint: "", + description: { + "en-US": "Agent to preview (required when the project declares multiple Agents)", + "zh-CN": "要预览的 Agent(项目包含多个 Agent 时需要指定)", + }, + }, +} satisfies FlagsDef; + +const WORKBENCH_NOTES = [ + ...CREDENTIALS_NOTE, + { + "en-US": + "Workbench requires Node.js 22+ and starts the shared @openagentpack/playground package locally. It does not push Git commits or switch branches.", + "zh-CN": + "Workbench 需要 Node.js 22+,并在本地启动共享的 @openagentpack/playground 包;不会推送 Git Commit 或切换分支。", + }, +]; + +export const managedAgentWorkbench = defineCommand({ + description: { + "en-US": "Launch the agents.yaml project Workbench", + "zh-CN": "启动 agents.yaml 项目 Workbench", + }, + auth: "apiKey", + usageArgs: "[--file ] [--port ] [--no-open]", + flags: WORKBENCH_FLAGS, + exampleArgs: ["", "--file agents.yaml --no-open", "--port 4949"], + notes: WORKBENCH_NOTES, + async run(ctx) { + const file = ctx.flags.file ?? "agents.yaml"; + const port = ctx.flags.port ?? 4848; + if (ctx.settings.dryRun) { + emitResult( + { + would_launch: "workbench", + config_file: file, + port, + open_browser: !ctx.flags.noOpen, + }, + detectOutputFormat(ctx.settings.output), + ); + return; + } + await launchManagedAgentPlayground({ + file, + port, + open: !ctx.flags.noOpen, + surface: "workbench", + client: ctx.client, + settings: ctx.settings, + }); + }, +}); + +export const managedAgentPlayground = defineCommand({ + description: { + "en-US": "Launch a Session Preview for an agents.yaml Agent", + "zh-CN": "为 agents.yaml 中的 Agent 启动会话预览", + }, + auth: "apiKey", + usageArgs: "[--file ] [--agent ] [--port ] [--no-open]", + flags: PLAYGROUND_FLAGS, + exampleArgs: ["", "--agent assistant", "--file agents.yaml --no-open"], + notes: WORKBENCH_NOTES, + async run(ctx) { + const file = ctx.flags.file ?? "agents.yaml"; + const port = ctx.flags.port ?? 4848; + if (ctx.settings.dryRun) { + emitResult( + { + would_launch: "playground", + config_file: file, + agent: ctx.flags.agent, + port, + open_browser: !ctx.flags.noOpen, + }, + detectOutputFormat(ctx.settings.output), + ); + return; + } + await launchManagedAgentPlayground({ + file, + agent: ctx.flags.agent, + port, + open: !ctx.flags.noOpen, + surface: "preview", + client: ctx.client, + settings: ctx.settings, + }); + }, +}); diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index 79e311e..abf3587 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -136,6 +136,18 @@ export { default as managedAgentValidate } from "./commands/managed-agent/valida export { default as managedAgentPlan } from "./commands/managed-agent/plan.ts"; export { default as managedAgentApply } from "./commands/managed-agent/apply.ts"; export { default as managedAgentDestroy } from "./commands/managed-agent/destroy.ts"; +export { + managedAgentPlayground, + managedAgentWorkbench, +} from "./commands/managed-agent/workbench.ts"; +export { + managedAgentVersionDisable, + managedAgentVersionEnable, + managedAgentVersionList, + managedAgentVersionPreview, + managedAgentVersionRestore, + managedAgentVersionStatus, +} from "./commands/managed-agent/version.ts"; export { default as managedAgentStateList } from "./commands/managed-agent/state-list.ts"; export { default as managedAgentStateShow } from "./commands/managed-agent/state-show.ts"; export { default as managedAgentStateRm } from "./commands/managed-agent/state-rm.ts"; diff --git a/packages/commands/tests/e2e/managed-agent.e2e.test.ts b/packages/commands/tests/e2e/managed-agent.e2e.test.ts index fc6b058..4034509 100644 --- a/packages/commands/tests/e2e/managed-agent.e2e.test.ts +++ b/packages/commands/tests/e2e/managed-agent.e2e.test.ts @@ -132,6 +132,27 @@ describe("e2e: managed-agent", () => { expect(stderr).toMatch(/--file|--provider|--yes/i); }); + test("managed-agent version 暴露共享版本管理子命令", async () => { + const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + "managed-agent", + "version", + "--help", + ]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/enable|disable|status|list|preview|restore/i); + }); + + test("managed-agent version preview 缺少 --commit 时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + "managed-agent", + "version", + "preview", + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--commit|Missing required/i); + }); + test("managed-agent session delete 缺少 --session-id 时退出为用法错误 (2)", async () => { const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ "managed-agent", @@ -218,6 +239,47 @@ describe("e2e: managed-agent(--dry-run 短路,不联网不写盘)", () => expect(data.provider).toBe("bailian"); }); + test("init --git --dry-run 仅输出仓库脚手架计划", async () => { + const targetDirectory = join( + process.cwd(), + `.managed-agent-git-dry-run-${process.pid}-${Date.now()}`, + ); + const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + "managed-agent", + "init", + "--git", + targetDirectory, + "--dry-run", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + would_initialize_git_project?: string; + mode?: string; + }>(stdout); + expect(data.would_initialize_git_project).toBe(targetDirectory); + expect(data.mode).toBe("create"); + }); + + test("workbench --dry-run 仅输出启动计划", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + "managed-agent", + "workbench", + "--dry-run", + "--no-open", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + would_launch?: string; + open_browser?: boolean; + }>(stdout); + expect(data.would_launch).toBe("workbench"); + expect(data.open_browser).toBe(false); + }); + test("apply --dry-run 仅输出计划", async () => { const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ "managed-agent", diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index fe249e8..df8bf3c 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -186,6 +186,14 @@ export const MANAGED_AGENT_ROUTES: E2eRouteExports = { "managed-agent plan": "managedAgentPlan", "managed-agent apply": "managedAgentApply", "managed-agent destroy": "managedAgentDestroy", + "managed-agent workbench": "managedAgentWorkbench", + "managed-agent playground": "managedAgentPlayground", + "managed-agent version enable": "managedAgentVersionEnable", + "managed-agent version disable": "managedAgentVersionDisable", + "managed-agent version status": "managedAgentVersionStatus", + "managed-agent version list": "managedAgentVersionList", + "managed-agent version preview": "managedAgentVersionPreview", + "managed-agent version restore": "managedAgentVersionRestore", "managed-agent state list": "managedAgentStateList", "managed-agent state rm": "managedAgentStateRm", "managed-agent state import": "managedAgentStateImport", diff --git a/packages/commands/tests/managed-agent-git-project.test.ts b/packages/commands/tests/managed-agent-git-project.test.ts new file mode 100644 index 0000000..4fe6305 --- /dev/null +++ b/packages/commands/tests/managed-agent-git-project.test.ts @@ -0,0 +1,67 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vite-plus/test"; +import { createGitProject } from "../src/commands/managed-agent/_engine/git-project.ts"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + for (const directory of temporaryDirectories.splice(0)) { + await rm(directory, { recursive: true, force: true }); + } +}); + +describe("managed-agent init --git project scaffolding", () => { + test("creates a main-branch Git project without committing or configuring a remote", async () => { + const parentDirectory = await mkdtemp(join(tmpdir(), "bailian-cli-git-project-")); + temporaryDirectories.push(parentDirectory); + const targetDirectory = join(parentDirectory, "agent-project"); + + const result = await createGitProject(targetDirectory, { + config: projectYaml(), + cliVersion: "1.17.1", + }); + + expect(result.mode).toBe("created"); + expect(result.initializedGit).toBe(true); + expect(result.createdFiles).toContain("agents.yaml"); + expect(await readFile(join(targetDirectory, "agents.yaml"), "utf8")).toContain("assistant:"); + expect(await readFile(join(targetDirectory, ".aoneci/bailian-cli.yml"), "utf8")).toContain( + "agents:apply:ci", + ); + expect(await readFile(join(targetDirectory, "README.md"), "utf8")).toContain( + "Create the remote repository yourself", + ); + }); + + test("upgrades an initialized config directory without overwriting agents.yaml", async () => { + const targetDirectory = await mkdtemp(join(tmpdir(), "bailian-cli-git-upgrade-")); + temporaryDirectories.push(targetDirectory); + const originalSource = projectYaml().replace("assistant", "reviewer"); + await writeFile(join(targetDirectory, "agents.yaml"), originalSource); + + const result = await createGitProject(targetDirectory, { + config: projectYaml(), + cliVersion: "1.17.1", + }); + + expect(result.mode).toBe("upgraded"); + expect(result.preservedFiles).toContain("agents.yaml"); + expect(await readFile(join(targetDirectory, "agents.yaml"), "utf8")).toBe(originalSource); + }); +}); + +function projectYaml(): string { + return `version: "1" +providers: + bailian: + api_key: \${DASHSCOPE_API_KEY} +defaults: + provider: bailian +agents: + assistant: + model: qwen3.8-max + instructions: You are helpful. +`; +} diff --git a/packages/commands/tests/managed-agent-local-git.test.ts b/packages/commands/tests/managed-agent-local-git.test.ts new file mode 100644 index 0000000..dfaa25a --- /dev/null +++ b/packages/commands/tests/managed-agent-local-git.test.ts @@ -0,0 +1,202 @@ +import { execFile } from "node:child_process"; +import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import type { PlannedAction } from "@openagentpack/sdk"; +import { afterEach, describe, expect, test } from "vite-plus/test"; +import { + commitAutomaticVersion, + disableLocalVersioning, + enableLocalVersioning, + getLocalVersionStatus, + prepareAutomaticVersion, + previewLocalVersion, + restoreLocalVersion, +} from "@openagentpack/local-git"; +import { playgroundBrowserTargetFromSummary } from "../src/commands/managed-agent/_engine/playground-launcher.ts"; +import { assertCiApplyPolicy } from "../src/commands/managed-agent/apply.ts"; + +const execFileAsync = promisify(execFile); +const temporaryDirectories: string[] = []; +const gitIdentity = { + GIT_AUTHOR_NAME: "Bailian CLI Test", + GIT_AUTHOR_EMAIL: "bailian-cli@example.com", + GIT_COMMITTER_NAME: "Bailian CLI Test", + GIT_COMMITTER_EMAIL: "bailian-cli@example.com", +}; + +afterEach(async () => { + for (const directory of temporaryDirectories.splice(0)) { + await rm(directory, { recursive: true, force: true }); + } +}); + +describe("managed-agent local Git versions", () => { + test("uses the shared path-scoped switch and commits only agents.yaml", async () => { + const root = await temporaryDirectory(); + const configPath = join(root, "agents.yaml"); + const nestedDirectory = join(root, "nested"); + const nestedConfigPath = join(nestedDirectory, "agents.yaml"); + await mkdir(nestedDirectory); + await writeFile(configPath, projectYaml("First")); + await writeFile(nestedConfigPath, projectYaml("Second")); + await git(root, ["init", "--initial-branch", "main"]); + await writeFile(join(root, "staged.txt"), "staged\n"); + await git(root, ["add", "staged.txt"]); + const stagedBefore = await git(root, ["status", "--porcelain=v1", "--", "staged.txt"]); + + const enabled = await withGitIdentity(() => + enableLocalVersioning(configPath, "Enable Bailian CLI versioning"), + ); + + expect(enabled.git.enabled).toBe(true); + expect((await getLocalVersionStatus(nestedConfigPath)).enabled).toBe(false); + expect((await git(root, ["show", "--pretty=", "--name-only", "HEAD"])).trim()).toBe( + "agents.yaml", + ); + expect(await git(root, ["status", "--porcelain=v1", "--", "staged.txt"])).toBe(stagedBefore); + expect( + await git(root, ["rev-parse", "--git-path", "openagentpack/local-git/versions"]), + ).toContain("openagentpack/local-git/versions"); + + await writeFile(configPath, projectYaml("First updated")); + const repeated = await withGitIdentity(() => + enableLocalVersioning(configPath, "Enable Bailian CLI versioning"), + ); + expect(repeated.version?.message).toBe("Enable Bailian CLI versioning"); + expect(await git(root, ["status", "--porcelain=v1", "--", "staged.txt"])).toBe(stagedBefore); + + const disabled = await disableLocalVersioning(configPath); + expect(disabled.enabled).toBe(false); + }); + + test("auto-commits after success and restores without moving HEAD or changing permissions", async () => { + const root = await temporaryDirectory(); + const configPath = join(root, "agents.yaml"); + await writeFile(configPath, projectYaml("Version one")); + await chmod(configPath, 0o640); + const enabled = await withGitIdentity(() => + enableLocalVersioning(configPath, "Enable Bailian CLI versioning"), + ); + const firstCommit = enabled.version!.commit; + const secondSource = projectYaml("Version two"); + await writeFile(configPath, secondSource); + + const prepared = await withGitIdentity(() => prepareAutomaticVersion(configPath, secondSource)); + const version = await withGitIdentity(() => commitAutomaticVersion(prepared!)); + const headBeforeRestore = (await git(root, ["rev-parse", "HEAD"])).trim(); + expect(version?.message).toBe("Apply agents.yaml"); + + const preview = await previewLocalVersion(configPath, firstCommit); + expect(preview.can_restore).toBe(true); + expect(preview.after_yaml).toContain("Version one"); + await restoreLocalVersion(configPath, firstCommit, { + head: preview.base_head, + sourceRevision: preview.base_source_revision, + }); + + expect(await readFile(configPath, "utf8")).toContain("Version one"); + expect((await git(root, ["rev-parse", "HEAD"])).trim()).toBe(headBeforeRestore); + expect((await stat(configPath)).mode & 0o777).toBe(0o640); + }); + + test("rejects short SHAs and plaintext credentials", async () => { + const root = await temporaryDirectory(); + const configPath = join(root, "agents.yaml"); + await writeFile(configPath, projectYaml("Safe")); + const enabled = await withGitIdentity(() => + enableLocalVersioning(configPath, "Enable Bailian CLI versioning"), + ); + await expect(previewLocalVersion(configPath, enabled.version!.short_commit)).rejects.toThrow( + /full hexadecimal commit SHA/i, + ); + + await disableLocalVersioning(configPath); + await writeFile( + configPath, + projectYaml("Unsafe").replace("qoder: {}", "qoder:\n api_key: plaintext-secret"), + ); + await expect( + withGitIdentity(() => enableLocalVersioning(configPath, "Enable Bailian CLI versioning")), + ).rejects.toThrow(/environment variable reference/i); + }); +}); + +describe("managed-agent CI and Workbench policies", () => { + test("CI blocks delete actions and remote drift", () => { + expect(() => assertCiApplyPolicy([plannedAction("delete")])).toThrow(/blocked.*delete/i); + expect(() => assertCiApplyPolicy([plannedAction("update", "remote")])).toThrow( + /blocked.*remote drift/i, + ); + expect(() => assertCiApplyPolicy([plannedAction("update", "local")])).not.toThrow(); + }); + + test("Session Preview opens the requested Agent or falls back to Workbench", () => { + const summary = { + status: "valid", + agents: [{ agent: { id: "assistant" } }, { agent: { id: "reviewer" } }], + }; + expect( + playgroundBrowserTargetFromSummary("http://localhost:4848", summary, "reviewer"), + ).toEqual({ url: "http://localhost:4848/agents/reviewer/preview" }); + expect(playgroundBrowserTargetFromSummary("http://localhost:4848", summary)).toEqual( + expect.objectContaining({ url: "http://localhost:4848", warning: expect.any(String) }), + ); + }); +}); + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "bailian-cli-local-git-")); + temporaryDirectories.push(directory); + return directory; +} + +function projectYaml(instructions: string): string { + return `version: "1" +providers: + qoder: {} +defaults: + provider: qoder +agents: + assistant: + model: ultimate + instructions: ${instructions} +`; +} + +function plannedAction( + action: "create" | "update" | "delete", + driftKind: "none" | "local" | "remote" | "both" = "none", +): PlannedAction { + return { + action, + driftKind, + address: { provider: "bailian", type: "agent", name: "assistant" }, + } as PlannedAction; +} + +async function git(workingDirectory: string, arguments_: string[]): Promise { + const result = await execFileAsync("git", arguments_, { + cwd: workingDirectory, + encoding: "utf8", + env: { ...process.env, ...gitIdentity }, + }); + return result.stdout; +} + +async function withGitIdentity(operation: () => Promise): Promise { + const previousEnvironment = Object.fromEntries( + Object.keys(gitIdentity).map((key) => [key, process.env[key]]), + ); + Object.assign(process.env, gitIdentity); + try { + return await operation(); + } finally { + for (const key of Object.keys(gitIdentity)) { + const value = previousEnvironment[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} diff --git a/skills/bailian-managed-agent/SKILL.md b/skills/bailian-managed-agent/SKILL.md index 7277707..aead755 100644 --- a/skills/bailian-managed-agent/SKILL.md +++ b/skills/bailian-managed-agent/SKILL.md @@ -6,7 +6,8 @@ metadata: bins: ["bl"] description: >- 阿里云百炼托管 Agent 声明式基础设施入口:用户要创建agent、初始化 agents.yaml、校验或预览 agent 配置变更、 - 创建/更新/销毁百炼托管 Agent 或 Deployment、和托管 agent 对话、查会话事件历史、导入或取消跟踪远端资源时使用 + 创建/更新/销毁百炼托管 Agent 或 Deployment、在 Workbench 编辑和调试已有声明、管理 agents.yaml 本地 Git 版本、 + 生成 CI 仓库、和托管 agent 对话、查会话事件历史、导入或取消跟踪远端资源时使用 `bl managed-agent`。以 agents.yaml 为唯一事实源做 IaC:init 建脚手架、validate 离线校验、plan 预览 diff、 apply / destroy 变更远端资源且必须带 `--yes`,务必先 plan 给用户看 diff 再让其确认。 反触发:调用已上线的百炼应用/智能体走 bailian-app-call 或 `bl app`;宿主 agent 自身的记忆、技能、 @@ -20,11 +21,12 @@ description: >- ## Safety guardrail (the most important rule) -`apply` / `destroy` **mutate remote resources** and only execute when `--yes` is passed: +`apply` / `destroy` **mutate remote resources**. Interactive execution requires `--yes`; `apply --ci` is only for an already approved CI workflow: 1. Always run `bl managed-agent plan` first and show the diff to the user. 2. Only after explicit user confirmation, retry `apply` / `destroy` with `--yes`. 3. Never add `--yes` on your own initiative before the user has confirmed. +4. Never use `--ci` to bypass user confirmation in an interactive task. CI mode blocks deletes and remote drift, but still mutates remote resources. ## IaC lifecycle @@ -36,6 +38,23 @@ description: >- 5. Destroy bl managed-agent destroy --yes # only after user confirmation ``` +## Workbench, local versions, and CI + +| Intent | Command | +| ------------------------------------------- | ---------------------------------------------- | +| Launch project resource editing | `bl managed-agent workbench` | +| Launch one Agent Session Preview | `bl managed-agent playground --agent ` | +| Create or upgrade a local Git/CI repository | `bl managed-agent init --git ` | +| Enable/disable shared automatic versions | `bl managed-agent version enable` / `disable` | +| Inspect local version state and history | `bl managed-agent version status` / `list` | +| Preview or restore a historical YAML | `bl managed-agent version preview` / `restore` | + +- Bailian CLI and Workbench use the same repository-local switch for the same Git worktree and `agents.yaml` path. The switch lives in private Git metadata and is not cloned or pushed. +- When enabled, a fully successful Apply creates a local commit containing only `agents.yaml`. Failed, partial, cancelled, and `--refresh-only` Apply runs do not commit. +- `version restore` writes the historical YAML to the working tree. It does not move `HEAD`, restore `agents.state.json`, create a commit, or Apply remote changes. +- Workbench can edit local drafts while Apply is running, but saving/version mutations are blocked until Apply completes. External file edits are detected through revision checks. +- `init --git` never creates a remote repository or pushes. The generated Aone CI uses `apply --ci`, which blocks deletes and remote drift; review destructive changes in a separately approved workflow. + ## Deployment as IaC Deployment 与 Agent 一样声明在 `agents.yaml` 中,并复用同一条 `validate → plan → apply → destroy` IaC 链路; diff --git a/skills/bailian-managed-agent/reference/index.md b/skills/bailian-managed-agent/reference/index.md index a401eac..a535388 100644 --- a/skills/bailian-managed-agent/reference/index.md +++ b/skills/bailian-managed-agent/reference/index.md @@ -9,31 +9,39 @@ Use this index for the skill-scoped quick index and global flags. ## Quick index -| Command | Authentication | Description | Detail | -| --------------------------------- | -------------- | ------------------------------------------------------------- | ------------------------------------ | -| `bl managed-agent apply` | API Key | Apply planned changes to create/update/delete agent resources | [managed-agent.md](managed-agent.md) | -| `bl managed-agent destroy` | API Key | Destroy all managed agent resources tracked in state | [managed-agent.md](managed-agent.md) | -| `bl managed-agent init` | No Auth | Create a new agents.yaml template | [managed-agent.md](managed-agent.md) | -| `bl managed-agent plan` | API Key | Show what changes would be applied to agent infrastructure | [managed-agent.md](managed-agent.md) | -| `bl managed-agent session create` | API Key | Create a new session for an agent | [managed-agent.md](managed-agent.md) | -| `bl managed-agent session delete` | API Key | Delete a session | [managed-agent.md](managed-agent.md) | -| `bl managed-agent session events` | API Key | List event history for a session | [managed-agent.md](managed-agent.md) | -| `bl managed-agent session get` | API Key | Get details of a session | [managed-agent.md](managed-agent.md) | -| `bl managed-agent session list` | API Key | List sessions from the provider | [managed-agent.md](managed-agent.md) | -| `bl managed-agent session run` | API Key | Create a session, send a message, and stream the response | [managed-agent.md](managed-agent.md) | -| `bl managed-agent session send` | API Key | Send a message to an existing session and stream the response | [managed-agent.md](managed-agent.md) | -| `bl managed-agent skill-list` | API Key | List skills from the provider's skill catalog | [managed-agent.md](managed-agent.md) | -| `bl managed-agent state import` | API Key | Import an existing remote resource into agents state | [managed-agent.md](managed-agent.md) | -| `bl managed-agent state list` | No Auth | List resources tracked in agents state | [managed-agent.md](managed-agent.md) | -| `bl managed-agent state rm` | No Auth | Remove a resource from state without destroying it remotely | [managed-agent.md](managed-agent.md) | -| `bl managed-agent state show` | No Auth | Show details of a resource in agents state | [managed-agent.md](managed-agent.md) | -| `bl managed-agent validate` | No Auth | Validate an agents.yaml configuration (offline) | [managed-agent.md](managed-agent.md) | +| Command | Authentication | Description | Detail | +| ---------------------------------- | -------------- | ------------------------------------------------------------- | ------------------------------------ | +| `bl managed-agent apply` | API Key | Apply planned changes to create/update/delete agent resources | [managed-agent.md](managed-agent.md) | +| `bl managed-agent destroy` | API Key | Destroy all managed agent resources tracked in state | [managed-agent.md](managed-agent.md) | +| `bl managed-agent init` | No Auth | Create an agents.yaml template or a local CI/Git project | [managed-agent.md](managed-agent.md) | +| `bl managed-agent plan` | API Key | Show what changes would be applied to agent infrastructure | [managed-agent.md](managed-agent.md) | +| `bl managed-agent playground` | API Key | Launch a Session Preview for an agents.yaml Agent | [managed-agent.md](managed-agent.md) | +| `bl managed-agent session create` | API Key | Create a new session for an agent | [managed-agent.md](managed-agent.md) | +| `bl managed-agent session delete` | API Key | Delete a session | [managed-agent.md](managed-agent.md) | +| `bl managed-agent session events` | API Key | List event history for a session | [managed-agent.md](managed-agent.md) | +| `bl managed-agent session get` | API Key | Get details of a session | [managed-agent.md](managed-agent.md) | +| `bl managed-agent session list` | API Key | List sessions from the provider | [managed-agent.md](managed-agent.md) | +| `bl managed-agent session run` | API Key | Create a session, send a message, and stream the response | [managed-agent.md](managed-agent.md) | +| `bl managed-agent session send` | API Key | Send a message to an existing session and stream the response | [managed-agent.md](managed-agent.md) | +| `bl managed-agent skill-list` | API Key | List skills from the provider's skill catalog | [managed-agent.md](managed-agent.md) | +| `bl managed-agent state import` | API Key | Import an existing remote resource into agents state | [managed-agent.md](managed-agent.md) | +| `bl managed-agent state list` | No Auth | List resources tracked in agents state | [managed-agent.md](managed-agent.md) | +| `bl managed-agent state rm` | No Auth | Remove a resource from state without destroying it remotely | [managed-agent.md](managed-agent.md) | +| `bl managed-agent state show` | No Auth | Show details of a resource in agents state | [managed-agent.md](managed-agent.md) | +| `bl managed-agent validate` | No Auth | Validate an agents.yaml configuration (offline) | [managed-agent.md](managed-agent.md) | +| `bl managed-agent version disable` | No Auth | Disable Apply-time Git versioning without removing history | [managed-agent.md](managed-agent.md) | +| `bl managed-agent version enable` | No Auth | Enable Apply-time Git versioning for agents.yaml | [managed-agent.md](managed-agent.md) | +| `bl managed-agent version list` | No Auth | List current-branch commits that changed agents.yaml | [managed-agent.md](managed-agent.md) | +| `bl managed-agent version preview` | No Auth | Preview a historical agents.yaml version | [managed-agent.md](managed-agent.md) | +| `bl managed-agent version restore` | No Auth | Restore a historical agents.yaml version to the working tree | [managed-agent.md](managed-agent.md) | +| `bl managed-agent version status` | No Auth | Show local Git versioning status for agents.yaml | [managed-agent.md](managed-agent.md) | +| `bl managed-agent workbench` | API Key | Launch the agents.yaml project Workbench | [managed-agent.md](managed-agent.md) | ## By group -| Group | Commands | Reference | -| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | -| `managed-agent` | `apply`, `destroy`, `init`, `plan`, `session create`, `session delete`, `session events`, `session get`, `session list`, `session run`, `session send`, `skill-list`, `state import`, `state list`, `state rm`, `state show`, `validate` | [managed-agent.md](managed-agent.md) | +| Group | Commands | Reference | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | +| `managed-agent` | `apply`, `destroy`, `init`, `plan`, `playground`, `session create`, `session delete`, `session events`, `session get`, `session list`, `session run`, `session send`, `skill-list`, `state import`, `state list`, `state rm`, `state show`, `validate`, `version disable`, `version enable`, `version list`, `version preview`, `version restore`, `version status`, `workbench` | [managed-agent.md](managed-agent.md) | ## Global flags diff --git a/skills/bailian-managed-agent/reference/managed-agent.md b/skills/bailian-managed-agent/reference/managed-agent.md index e3975b7..28a52ee 100644 --- a/skills/bailian-managed-agent/reference/managed-agent.md +++ b/skills/bailian-managed-agent/reference/managed-agent.md @@ -7,36 +7,44 @@ Index: [index.md](index.md) ## Commands in this group -| Command | Authentication | Description | -| --------------------------------- | -------------- | ------------------------------------------------------------- | -| `bl managed-agent apply` | API Key | Apply planned changes to create/update/delete agent resources | -| `bl managed-agent destroy` | API Key | Destroy all managed agent resources tracked in state | -| `bl managed-agent init` | No Auth | Create a new agents.yaml template | -| `bl managed-agent plan` | API Key | Show what changes would be applied to agent infrastructure | -| `bl managed-agent session create` | API Key | Create a new session for an agent | -| `bl managed-agent session delete` | API Key | Delete a session | -| `bl managed-agent session events` | API Key | List event history for a session | -| `bl managed-agent session get` | API Key | Get details of a session | -| `bl managed-agent session list` | API Key | List sessions from the provider | -| `bl managed-agent session run` | API Key | Create a session, send a message, and stream the response | -| `bl managed-agent session send` | API Key | Send a message to an existing session and stream the response | -| `bl managed-agent skill-list` | API Key | List skills from the provider's skill catalog | -| `bl managed-agent state import` | API Key | Import an existing remote resource into agents state | -| `bl managed-agent state list` | No Auth | List resources tracked in agents state | -| `bl managed-agent state rm` | No Auth | Remove a resource from state without destroying it remotely | -| `bl managed-agent state show` | No Auth | Show details of a resource in agents state | -| `bl managed-agent validate` | No Auth | Validate an agents.yaml configuration (offline) | +| Command | Authentication | Description | +| ---------------------------------- | -------------- | ------------------------------------------------------------- | +| `bl managed-agent apply` | API Key | Apply planned changes to create/update/delete agent resources | +| `bl managed-agent destroy` | API Key | Destroy all managed agent resources tracked in state | +| `bl managed-agent init` | No Auth | Create an agents.yaml template or a local CI/Git project | +| `bl managed-agent plan` | API Key | Show what changes would be applied to agent infrastructure | +| `bl managed-agent playground` | API Key | Launch a Session Preview for an agents.yaml Agent | +| `bl managed-agent session create` | API Key | Create a new session for an agent | +| `bl managed-agent session delete` | API Key | Delete a session | +| `bl managed-agent session events` | API Key | List event history for a session | +| `bl managed-agent session get` | API Key | Get details of a session | +| `bl managed-agent session list` | API Key | List sessions from the provider | +| `bl managed-agent session run` | API Key | Create a session, send a message, and stream the response | +| `bl managed-agent session send` | API Key | Send a message to an existing session and stream the response | +| `bl managed-agent skill-list` | API Key | List skills from the provider's skill catalog | +| `bl managed-agent state import` | API Key | Import an existing remote resource into agents state | +| `bl managed-agent state list` | No Auth | List resources tracked in agents state | +| `bl managed-agent state rm` | No Auth | Remove a resource from state without destroying it remotely | +| `bl managed-agent state show` | No Auth | Show details of a resource in agents state | +| `bl managed-agent validate` | No Auth | Validate an agents.yaml configuration (offline) | +| `bl managed-agent version disable` | No Auth | Disable Apply-time Git versioning without removing history | +| `bl managed-agent version enable` | No Auth | Enable Apply-time Git versioning for agents.yaml | +| `bl managed-agent version list` | No Auth | List current-branch commits that changed agents.yaml | +| `bl managed-agent version preview` | No Auth | Preview a historical agents.yaml version | +| `bl managed-agent version restore` | No Auth | Restore a historical agents.yaml version to the working tree | +| `bl managed-agent version status` | No Auth | Show local Git versioning status for agents.yaml | +| `bl managed-agent workbench` | API Key | Launch the agents.yaml project Workbench | ## Command details ### `bl managed-agent apply` -| Field | Value | -| ------------------ | ---------------------------------------------------------------------------------------- | -| **Name** | `managed-agent apply` | -| **Description** | Apply planned changes to create/update/delete agent resources | -| **Authentication** | API Key | -| **Usage** | `bl managed-agent apply [--file ] [--provider ] [--yes] [--concurrency ]` | +| Field | Value | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `managed-agent apply` | +| **Description** | Apply planned changes to create/update/delete agent resources | +| **Authentication** | API Key | +| **Usage** | `bl managed-agent apply [--file ] [--provider ] [--yes \| --ci] [--no-refresh] [--refresh-only] [--concurrency ]` | #### Flags @@ -45,7 +53,9 @@ Index: [index.md](index.md) | `--file ` | string | no | Config file path (default: agents.yaml) | | `--provider ` | string | no | Target provider (default: all configured) | | `--yes` | switch | no | Confirm and apply without an interactive prompt (required to mutate) | +| `--ci` | switch | no | Run non-interactively while blocking deletes and remote drift | | `--no-refresh` | switch | no | Skip refreshing state from remote before planning | +| `--refresh-only` | switch | no | Refresh state without mutating remote resources | | `--concurrency ` | number | no | Max independent resources to apply in parallel (default 6, max 10) | | `--api-key ` | string | no | API key | | `--base-url ` | string | no | API base URL | @@ -66,6 +76,10 @@ bl managed-agent apply --yes bl managed-agent apply --provider bailian --yes ``` +```bash +bl managed-agent apply --ci +``` + ### `bl managed-agent destroy` | Field | Value | @@ -103,12 +117,12 @@ bl managed-agent destroy --yes --cascade ### `bl managed-agent init` -| Field | Value | -| ------------------ | ------------------------------------------------------------------------------------------- | -| **Name** | `managed-agent init` | -| **Description** | Create a new agents.yaml template | -| **Authentication** | No Auth | -| **Usage** | `bl managed-agent init [--provider ] [--agent-name ] [--file ] [--force]` | +| Field | Value | +| ------------------ | --------------------------------------------------------------------------------------------------------------- | +| **Name** | `managed-agent init` | +| **Description** | Create an agents.yaml template or a local CI/Git project | +| **Authentication** | No Auth | +| **Usage** | `bl managed-agent init [--provider ] [--agent-name ] [--file ] [--git ] [--force]` | #### Flags @@ -117,6 +131,7 @@ bl managed-agent destroy --yes --cascade | `--provider ` | string | no | Provider: bailian, claude, qoder, ark, all (default: bailian) | | `--agent-name ` | string | no | Name of the first agent (default: assistant) | | `--file ` | string | no | Output config path (default: agents.yaml) | +| `--git ` | string | no | Create or add CI/Git scaffolding in this project directory | | `--force` | switch | no | Overwrite an existing config file | #### Examples @@ -130,7 +145,11 @@ bl managed-agent init --provider bailian --agent-name assistant ``` ```bash -bl managed-agent init --provider all +bl managed-agent init --git ./my-agents +``` + +```bash +bl managed-agent init --git . ``` ### `bl managed-agent plan` @@ -174,6 +193,47 @@ bl managed-agent plan --provider bailian bl managed-agent plan --no-refresh ``` +### `bl managed-agent playground` + +| Field | Value | +| ------------------ | ------------------------------------------------------------------------------------- | +| **Name** | `managed-agent playground` | +| **Description** | Launch a Session Preview for an agents.yaml Agent | +| **Authentication** | API Key | +| **Usage** | `bl managed-agent playground [--file ] [--agent ] [--port ] [--no-open]` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------ | ------ | -------- | --------------------------------------------------------------------- | +| `--file ` | string | no | Config file path (default: agents.yaml) | +| `--port ` | number | no | Local port (default: 4848) | +| `--no-open` | switch | no | Do not open a browser automatically | +| `--agent ` | string | no | Agent to preview (required when the project declares multiple Agents) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile). +- Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json. +- Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env. +- Workbench requires Node.js 22+ and starts the shared @openagentpack/playground package locally. It does not push Git commits or switch branches. + +#### Examples + +```bash +bl managed-agent playground +``` + +```bash +bl managed-agent playground --agent assistant +``` + +```bash +bl managed-agent playground --file agents.yaml --no-open +``` + ### `bl managed-agent session create` | Field | Value | @@ -618,3 +678,198 @@ bl managed-agent validate ```bash bl managed-agent validate --file agents.yaml ``` + +### `bl managed-agent version disable` + +| Field | Value | +| ------------------ | ---------------------------------------------------------- | +| **Name** | `managed-agent version disable` | +| **Description** | Disable Apply-time Git versioning without removing history | +| **Authentication** | No Auth | +| **Usage** | `bl managed-agent version disable [--file ]` | + +#### Flags + +| Flag | Type | Required | Description | +| --------------- | ------ | -------- | --------------------------------------- | +| `--file ` | string | no | Config file path (default: agents.yaml) | + +#### Examples + +```bash +bl managed-agent version disable +``` + +```bash +bl managed-agent version disable --file agents.yaml +``` + +### `bl managed-agent version enable` + +| Field | Value | +| ------------------ | ------------------------------------------------- | +| **Name** | `managed-agent version enable` | +| **Description** | Enable Apply-time Git versioning for agents.yaml | +| **Authentication** | No Auth | +| **Usage** | `bl managed-agent version enable [--file ]` | + +#### Flags + +| Flag | Type | Required | Description | +| --------------- | ------ | -------- | --------------------------------------- | +| `--file ` | string | no | Config file path (default: agents.yaml) | + +#### Examples + +```bash +bl managed-agent version enable +``` + +```bash +bl managed-agent version enable --file agents.yaml +``` + +### `bl managed-agent version list` + +| Field | Value | +| ------------------ | --------------------------------------------------------------------------------- | +| **Name** | `managed-agent version list` | +| **Description** | List current-branch commits that changed agents.yaml | +| **Authentication** | No Auth | +| **Usage** | `bl managed-agent version list [--file ] [--limit ] [--cursor ]` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------- | ------ | -------- | -------------------------------------------------- | +| `--file ` | string | no | Config file path (default: agents.yaml) | +| `--limit ` | number | no | Maximum versions to return (default: 50, max: 100) | +| `--cursor ` | string | no | Pagination cursor returned by the previous page | + +#### Examples + +```bash +bl managed-agent version list +``` + +```bash +bl managed-agent version list --limit 20 --output json +``` + +### `bl managed-agent version preview` + +| Field | Value | +| ------------------ | ---------------------------------------------------------------------- | +| **Name** | `managed-agent version preview` | +| **Description** | Preview a historical agents.yaml version | +| **Authentication** | No Auth | +| **Usage** | `bl managed-agent version preview --commit [--file ]` | + +#### Flags + +| Flag | Type | Required | Description | +| --------------------- | ------ | -------- | --------------------------------------- | +| `--file ` | string | no | Config file path (default: agents.yaml) | +| `--commit ` | string | yes | Full commit SHA from the current branch | + +#### Examples + +```bash +bl managed-agent version preview --commit +``` + +```bash +bl managed-agent version preview --commit --output json +``` + +### `bl managed-agent version restore` + +| Field | Value | +| ------------------ | ------------------------------------------------------------------------------ | +| **Name** | `managed-agent version restore` | +| **Description** | Restore a historical agents.yaml version to the working tree | +| **Authentication** | No Auth | +| **Usage** | `bl managed-agent version restore --commit [--file ] [--yes]` | + +#### Flags + +| Flag | Type | Required | Description | +| --------------------- | ------ | -------- | ------------------------------------------- | +| `--file ` | string | no | Config file path (default: agents.yaml) | +| `--commit ` | string | yes | Full commit SHA from the current branch | +| `--yes` | switch | no | Restore without an interactive confirmation | + +#### Examples + +```bash +bl managed-agent version restore --commit +``` + +```bash +bl managed-agent version restore --commit --yes --output json +``` + +### `bl managed-agent version status` + +| Field | Value | +| ------------------ | ------------------------------------------------- | +| **Name** | `managed-agent version status` | +| **Description** | Show local Git versioning status for agents.yaml | +| **Authentication** | No Auth | +| **Usage** | `bl managed-agent version status [--file ]` | + +#### Flags + +| Flag | Type | Required | Description | +| --------------- | ------ | -------- | --------------------------------------- | +| `--file ` | string | no | Config file path (default: agents.yaml) | + +#### Examples + +```bash +bl managed-agent version status +``` + +```bash +bl managed-agent version status --file agents.yaml --output json +``` + +### `bl managed-agent workbench` + +| Field | Value | +| ------------------ | --------------------------------------------------------------------- | +| **Name** | `managed-agent workbench` | +| **Description** | Launch the agents.yaml project Workbench | +| **Authentication** | API Key | +| **Usage** | `bl managed-agent workbench [--file ] [--port ] [--no-open]` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------ | ------ | -------- | --------------------------------------- | +| `--file ` | string | no | Config file path (default: agents.yaml) | +| `--port ` | number | no | Local port (default: 4848) | +| `--no-open` | switch | no | Do not open a browser automatically | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile). +- Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json. +- Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env. +- Workbench requires Node.js 22+ and starts the shared @openagentpack/playground package locally. It does not push Git commits or switch branches. + +#### Examples + +```bash +bl managed-agent workbench +``` + +```bash +bl managed-agent workbench --file agents.yaml --no-open +``` + +```bash +bl managed-agent workbench --port 4949 +```