diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 2cbda30..f9fd510 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -95,6 +95,8 @@ import { skillList, managedAgentInit, managedAgentValidate, + managedAgentSync, + managedAgentMigrate, managedAgentPlan, managedAgentApply, managedAgentDestroy, @@ -213,6 +215,8 @@ export const commands: Record = { "skill list": skillList, "managed-agent init": managedAgentInit, "managed-agent validate": managedAgentValidate, + "managed-agent sync": managedAgentSync, + "managed-agent migrate": managedAgentMigrate, "managed-agent plan": managedAgentPlan, "managed-agent apply": managedAgentApply, "managed-agent destroy": managedAgentDestroy, diff --git a/packages/commands/src/commands/managed-agent/_engine/sync-selection.ts b/packages/commands/src/commands/managed-agent/_engine/sync-selection.ts new file mode 100644 index 0000000..9529e9b --- /dev/null +++ b/packages/commands/src/commands/managed-agent/_engine/sync-selection.ts @@ -0,0 +1,221 @@ +import { + listCloudEnvironments, + listCloudVaults, + listFiles, + listSkills, + type ProjectRuntimeContext, + type SyncProjectResult, +} from "@openagentpack/sdk"; +import { BailianError, ExitCode } from "bailian-cli-core"; + +/** Resource types the SDK can reverse-export (its syncable whitelist). */ +export const SYNCABLE_TYPES = ["environment", "vault", "file", "skill", "agent"] as const; + +export type SyncableType = (typeof SYNCABLE_TYPES)[number]; + +/** + * Types selectable by remote ID through the shared resolution below. Agents are + * excluded: their synced yaml keys ARE remote IDs, so the sync command narrows + * them with a direct key hit (plus referenced-skill handling) instead. + */ +export type SelectableResourceType = Exclude; + +/** Maps a syncable type to its top-level group key in the synced config. */ +const GROUP_KEY: Record = { + environment: "environments", + vault: "vaults", + file: "files", + skill: "skills", + agent: "agents", +}; + +/** Remote id lookup outcome: human label + optional `agents.resource` yaml key tag. */ +interface LocatedRemote { + label?: string; + taggedKey?: string; +} + +/** + * Narrow one resource group of a synced config to the single entry identified + * by its remote ID; returns the kept yaml key. + * + * Group keys are only guaranteed to be remote IDs for agents; the other types + * derive their key from the `agents.resource` metadata tag or a display-name + * slug. Resolution therefore tries, in order: direct key hit → remote lookup + * by ID (provider list API) → metadata-tag key → label match against the + * exported declarations. Misses and ambiguity fail loudly instead of guessing. + */ +export async function narrowGroupToRemoteId( + runtime: ProjectRuntimeContext, + provider: string, + result: SyncProjectResult, + type: SelectableResourceType, + remoteId: string, +): Promise { + const groupKey = GROUP_KEY[type]; + const group = (result.config[groupKey] ?? {}) as Record>; + + let keptKey: string; + if (remoteId in group) { + keptKey = remoteId; + } else { + const located = await locateRemote(runtime, provider, type, remoteId); + const resolved = + located.taggedKey && located.taggedKey in group + ? located.taggedKey + : matchByLabel(type, group, located.label); + if (!resolved) { + throw new BailianError( + `Remote ${type} '${remoteId}'${located.label ? ` (${located.label})` : ""} has no matching entry in the synced output.`, + ExitCode.GENERAL, + "The resource may be archived or renamed; run a full sync (without the id flag) to inspect the exported keys.", + ); + } + keptKey = resolved; + } + + result.config[groupKey] = { [keptKey]: group[keptKey]! }; + if (type in result.counts) result.counts[type] = 1; + if (type === "skill" && result.skillFiles) { + for (const skillName of result.skillFiles.keys()) { + if (skillName !== keptKey) result.skillFiles.delete(skillName); + } + } + return keptKey; +} + +/** Look a remote resource up by ID via the provider's list API. */ +async function locateRemote( + runtime: ProjectRuntimeContext, + provider: string, + type: SelectableResourceType, + remoteId: string, +): Promise { + if (type === "environment") { + const environments = await listCloudEnvironments(runtime, { provider }); + const hit = environments.find((environment) => environment.id === remoteId); + if (!hit) { + throw notFound( + type, + remoteId, + environments.map((environment) => formatCandidate(environment.id, environment.name)), + ); + } + return { label: hit.name, taggedKey: hit.metadata?.["agents.resource"] }; + } + if (type === "vault") { + const vaults = await listCloudVaults(runtime, { provider }); + const hit = vaults.find((vault) => vault.id === remoteId); + if (!hit) { + throw notFound( + type, + remoteId, + vaults.map((vault) => formatCandidate(vault.id, vault.display_name)), + ); + } + return { label: hit.display_name, taggedKey: hit.metadata?.["agents.resource"] }; + } + if (type === "file") { + const files = await listFiles(runtime, { provider }); + const hit = files.find((fileInfo) => fileInfo.id === remoteId); + if (!hit) { + throw notFound( + type, + remoteId, + files.map((fileInfo) => formatCandidate(fileInfo.id, fileInfo.filename)), + ); + } + return { label: hit.filename }; + } + // Synced skills come from the workspace's custom catalog (the raw /skills listing). + const skills = await listSkills(runtime, { provider, source: "custom" }); + const hit = skills.find((skill) => skill.id === remoteId); + if (!hit) { + throw notFound( + type, + remoteId, + skills.map((skill) => formatCandidate(skill.id, skill.name)), + ); + } + return { label: hit.name }; +} + +/** + * Batch-resolve remote skill IDs to synced skills-group keys via the custom + * skill catalog: look each ID up for its display name, then match group keys + * on normalized label (skills keys are display-name slugs). IDs that cannot be + * resolved — or whose label matches more than one key — come back as + * unmatched for the caller to surface. One catalog call serves the whole batch. + */ +export async function resolveSkillKeysByIds( + runtime: ProjectRuntimeContext, + provider: string, + group: Record>, + remoteIds: string[], +): Promise<{ resolved: Map; unmatched: string[] }> { + const resolved = new Map(); + const unmatched: string[] = []; + if (remoteIds.length === 0) return { resolved, unmatched }; + + const catalog = await listSkills(runtime, { provider, source: "custom" }); + for (const remoteId of remoteIds) { + const hit = catalog.find((skill) => skill.id === remoteId); + const matches = hit + ? Object.keys(group).filter((key) => normalizeLabel(key) === normalizeLabel(hit.name)) + : []; + if (matches.length === 1) { + resolved.set(remoteId, matches[0]!); + } else { + unmatched.push(remoteId); + } + } + return { resolved, unmatched }; +} + +/** + * Match a group entry by the remote resource's human label. Vaults and files + * carry the label verbatim in their exported decl; environment and skill keys + * are display-name slugs, so those compare on normalized alphanumerics. + */ +function matchByLabel( + type: SelectableResourceType, + group: Record>, + label: string | undefined, +): string | undefined { + if (!label) return undefined; + const matches = Object.entries(group).filter(([key, decl]) => { + if (type === "vault") return decl.display_name === label; + if (type === "file") return decl.name === label || decl.source === label; + return normalizeLabel(key) === normalizeLabel(label); + }); + if (matches.length > 1) { + throw new BailianError( + `Multiple synced ${type} entries match '${label}': ${matches.map(([key]) => key).join(", ")}.`, + ExitCode.GENERAL, + "Run a full sync (without the id flag) and narrow the output manually.", + ); + } + return matches[0]?.[0]; +} + +/** Compare display labels and slug-derived keys on lowercase alphanumerics only. */ +function normalizeLabel(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +function notFound(type: string, remoteId: string, candidates: string[]): BailianError { + const shown = candidates.slice(0, 20); + const suffix = + candidates.length > shown.length ? `, … ${candidates.length - shown.length} more` : ""; + return new BailianError( + `${type} '${remoteId}' not found on the remote workspace.`, + ExitCode.USAGE, + candidates.length > 0 + ? `Available: ${shown.join(", ")}${suffix}.` + : `The workspace has no syncable ${type} resources.`, + ); +} + +function formatCandidate(id: string, label?: string): string { + return label && label !== id ? `${id} (${label})` : id; +} diff --git a/packages/commands/src/commands/managed-agent/migrate.ts b/packages/commands/src/commands/managed-agent/migrate.ts new file mode 100644 index 0000000..24c6aa3 --- /dev/null +++ b/packages/commands/src/commands/managed-agent/migrate.ts @@ -0,0 +1,123 @@ +import { existsSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; +import { + BailianError, + defineCommand, + detectOutputFormat, + ExitCode, + type FlagsDef, +} from "bailian-cli-core"; +import { emitBare, emitResult } from "bailian-cli-runtime"; +import { migrateConfig } from "@openagentpack/sdk"; +import { parse as parseYaml } from "yaml"; +import { withAgentErrors } from "./_engine/errors.ts"; + +/** bl's migrate is bailian-only: the merge target must resolve to bailian. */ +const MIGRATE_PROVIDER = "bailian"; + +const MIGRATE_FLAGS = { + from: { + type: "string", + valueHint: "", + description: "Synced config to migrate from (default: agents.synced.yaml)", + }, + to: { + type: "string", + valueHint: "", + description: "Target agents.yaml to merge into (default: agents.yaml)", + }, +} satisfies FlagsDef; + +export default defineCommand({ + description: "Merge a synced config into a bailian agents.yaml", + auth: "apiKey", + usageArgs: "[--from ] [--to ]", + flags: MIGRATE_FLAGS, + exampleArgs: ["", "--from agents.synced.yaml --to agents.yaml"], + notes: [ + "The merge itself runs against local files; bl's unified apiKey gate still applies — login via `bl auth login`, pass --api-key, or set DASHSCOPE_API_KEY.", + "Only a bailian-target agents.yaml is supported: migrated resources are re-pointed to provider bailian, with models/tools/environments normalized to Bailian-supported values.", + "Resources whose YAML key already exists in the target are skipped, never overwritten.", + "Run `bl managed-agent plan` afterwards to review the merged config before apply.", + ], + async run(ctx) { + const { settings, flags } = ctx; + const format = detectOutputFormat(settings.output); + const fromPath = flags.from ?? "agents.synced.yaml"; + const toPath = flags.to ?? "agents.yaml"; + + // Aligned with the other managed-agent commands: dry-run short-circuits + // first and only echoes the planned action — no file I/O, no validation. + if (settings.dryRun) { + emitResult({ would_migrate: { from: fromPath, to: toPath } }, format); + return; + } + + const result = await withAgentErrors(async () => { + await assertBailianTarget(toPath); + return migrateConfig({ fromPath, toPath }); + }); + + await writeFile(toPath, result.yaml, "utf8"); + + if (format === "json") { + emitResult( + { migrated: toPath, from: fromPath, added: result.added, skipped: result.skipped }, + format, + ); + return; + } + + const addedParts = Object.entries(result.added).map(([group, count]) => `${count} ${group}`); + const skippedParts = Object.entries(result.skipped).map( + ([group, count]) => `${count} ${group}`, + ); + + if (addedParts.length > 0) { + emitBare(`Migrated ${addedParts.join(", ")} into ${toPath}.`); + } else { + emitBare("No new resources to migrate (all already exist in target)."); + } + if (skippedParts.length > 0) { + emitBare(`Skipped (already exist): ${skippedParts.join(", ")}.`); + } + if (addedParts.length > 0) { + // `bl` prefix is safe: agent commands ship on `bl` only. + emitBare("Next: run `bl managed-agent plan` to review the merged config."); + } + }, +}); + +/** + * Enforce the bailian-only contract before merging: the target file must exist + * and its provider (defaults.provider, else the first providers key) must be + * bailian. An undeterminable provider is left to the SDK's own error. + */ +async function assertBailianTarget(toPath: string): Promise { + if (!existsSync(toPath)) { + throw new BailianError( + `Target file '${toPath}' not found.`, + ExitCode.USAGE, + // `bl` prefix is safe: agent commands ship on `bl` only. + "Create it first with `bl managed-agent init`, then re-run migrate.", + ); + } + const parsed: unknown = parseYaml(await readFile(toPath, "utf8")); + if (!parsed || typeof parsed !== "object") return; + const config = parsed as Record; + + const defaults = config.defaults as Record | undefined; + const providers = config.providers as Record | undefined; + const targetProvider = + typeof defaults?.provider === "string" && defaults.provider + ? defaults.provider + : Object.keys(providers ?? {})[0]; + + if (targetProvider && targetProvider !== MIGRATE_PROVIDER) { + throw new BailianError( + `Target provider '${targetProvider}' is not supported: migrate only targets the ${MIGRATE_PROVIDER} provider.`, + ExitCode.USAGE, + `Set defaults.provider to ${MIGRATE_PROVIDER} (or make ${MIGRATE_PROVIDER} the providers block) in '${toPath}'.`, + ); + } +} diff --git a/packages/commands/src/commands/managed-agent/sync.ts b/packages/commands/src/commands/managed-agent/sync.ts new file mode 100644 index 0000000..1f51e16 --- /dev/null +++ b/packages/commands/src/commands/managed-agent/sync.ts @@ -0,0 +1,465 @@ +import { existsSync, readdirSync, statSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { + BailianError, + defineCommand, + detectOutputFormat, + ExitCode, + type FlagsDef, +} from "bailian-cli-core"; +import { emitBare, emitResult } from "bailian-cli-runtime"; +import { + type ProjectRuntimeContext, + type SyncProjectResult, + syncProviderResourcesFromContext, +} from "@openagentpack/sdk"; +import { stringify as stringifyYaml } from "yaml"; +import { + assertProviderConfigured, + buildAgentRuntime, + CREDENTIALS_NOTE, +} from "./_engine/config-loader.ts"; +import { withStdoutProtected } from "./_engine/console-capture.ts"; +import { withAgentErrors } from "./_engine/errors.ts"; +import { + narrowGroupToRemoteId, + resolveSkillKeysByIds, + type SelectableResourceType, + SYNCABLE_TYPES, + type SyncableType, +} from "./_engine/sync-selection.ts"; + +/** bl's sync is bailian-only: the reverse-export source is always AgentStudio. */ +const SYNC_PROVIDER = "bailian"; + +const DEFAULT_SYNC_OUTPUT = "agents.synced.yaml"; + +const SYNC_FLAGS = { + file: { + type: "string", + valueHint: "", + description: "Config file path (default: agents.yaml)", + }, + out: { + type: "string", + valueHint: "", + description: `Output path for the synced config (default: ${DEFAULT_SYNC_OUTPUT})`, + }, + types: { + type: "string", + valueHint: "", + description: `Comma-separated resource types to sync: ${SYNCABLE_TYPES.join(", ")} (default: all)`, + }, + agentId: { + type: "string", + valueHint: "", + description: "Sync a single agent by its remote ID (plus the skills it references)", + }, + environmentId: { + type: "string", + valueHint: "", + description: "Sync a single environment by its remote ID", + }, + vaultId: { + type: "string", + valueHint: "", + description: "Sync a single vault by its remote ID", + }, + fileId: { + type: "string", + valueHint: "", + description: "Sync a single file resource by its remote ID", + }, + skillId: { + type: "string", + valueHint: "", + description: "Sync a single skill by its remote ID (overrides --agent-id skill narrowing)", + }, + force: { + type: "switch", + description: "Overwrite an existing output file", + }, + skipMissingFiles: { + type: "switch", + description: "Drop file resources whose local source is missing instead of keeping them", + }, +} satisfies FlagsDef; + +export default defineCommand({ + description: "Export remote bailian resources into a local synced config", + auth: "apiKey", + usageArgs: + "[--types ] [--agent-id|--environment-id|--vault-id|--file-id|--skill-id ] [--file ] [--out ] [--force] [--skip-missing-files]", + flags: SYNC_FLAGS, + exampleArgs: [ + "", + "--types agent,skill", + "--agent-id agent-abc123", + "--skill-id skill-xyz --types skill", + "--force --skip-missing-files", + ], + notes: [ + ...CREDENTIALS_NOTE, + "Syncs from the bailian provider only: remote AgentStudio resources (environments, vaults, files, skills, agents) are exported into a local synced config for review.", + "--types narrows the export to the listed resource types (plural spellings accepted); sessions are runtime instances, not syncable resources.", + "With --agent-id, the agents group keeps only that agent (yaml key = remote agent ID) and its referenced custom skills are synced along — the skill group is exported even when --types omits it; official skill references need no local entry.", + "Bailian binds environments/vaults/files at the session level, not on the agent, so --agent-id keeps those groups as shared infrastructure.", + "Each --environment-id/--vault-id/--file-id/--skill-id narrows its own resource group to the single remote resource (yaml key resolved from the remote listing); combine with --types for a minimal output.", + "Requires an agents.yaml with a bailian provider block — run `bl managed-agent init` first.", + "Secrets are never exported: vault credentials keep ${ENV} placeholders; set those env vars locally before apply.", + "Merge the synced config into agents.yaml with `bl managed-agent migrate`.", + ], + validate: (flagValues) => { + if (!flagValues.types) return undefined; + const { types, invalid } = parseSyncTypes(flagValues.types); + if (invalid.length > 0) { + return `--types contains unsupported values: ${invalid.join(", ")}. Valid types: ${SYNCABLE_TYPES.join(", ")}.`; + } + if (types.length === 0) { + return `--types must list at least one of: ${SYNCABLE_TYPES.join(", ")}.`; + } + const idFlagByType: Record = { + agent: flagValues.agentId, + environment: flagValues.environmentId, + vault: flagValues.vaultId, + file: flagValues.fileId, + skill: flagValues.skillId, + }; + for (const [selectionType, value] of Object.entries(idFlagByType)) { + if (value && !types.includes(selectionType as SyncableType)) { + return `--${selectionType}-id requires --types to include ${selectionType}.`; + } + } + return undefined; + }, + async run(ctx) { + const { settings, flags } = ctx; + const format = detectOutputFormat(settings.output); + const file = flags.file ?? "agents.yaml"; + const out = flags.out ?? DEFAULT_SYNC_OUTPUT; + // undefined → the SDK exports every syncable type. + let types = flags.types ? parseSyncTypes(flags.types).types : undefined; + // --agent-id pulls the agent's referenced skills along, so the skill group + // is exported even when --types omits it. + if (types && flags.agentId && !types.includes("skill")) types = [...types, "skill"]; + // Per-group single-resource selections, resolved by remote ID after export. + const resourceSelections: Partial> = { + environment: flags.environmentId, + vault: flags.vaultId, + file: flags.fileId, + skill: flags.skillId, + }; + + // Aligned with the other managed-agent commands: dry-run short-circuits + // first and only echoes the planned action — no guards, no file I/O. + if (settings.dryRun) { + emitResult( + { + would_sync: { + provider: SYNC_PROVIDER, + config_file: file, + out, + types, + agent_id: flags.agentId, + environment_id: flags.environmentId, + vault_id: flags.vaultId, + file_id: flags.fileId, + skill_id: flags.skillId, + }, + }, + format, + ); + return; + } + + if (existsSync(out) && !flags.force) { + throw new BailianError( + `${out} already exists.`, + ExitCode.USAGE, + "Pass --force to overwrite, or --out to write elsewhere.", + ); + } + + const { result, agentFilter, selectedKeys } = await withAgentErrors(() => + withStdoutProtected(async () => { + const runtime = await buildAgentRuntime(ctx, file); + assertProviderConfigured(runtime, SYNC_PROVIDER); + const synced = await syncProviderResourcesFromContext(runtime, { + provider: SYNC_PROVIDER, + types, + }); + // An explicit --skill-id wins over the agent's referenced-skill narrowing. + const filtered = flags.agentId + ? await filterConfigToAgent(runtime, synced, flags.agentId, { + narrowSkills: !flags.skillId, + }) + : undefined; + const keptKeys: Partial> = {}; + for (const [selectionType, remoteId] of Object.entries(resourceSelections)) { + if (!remoteId) continue; + keptKeys[selectionType as SelectableResourceType] = await narrowGroupToRemoteId( + runtime, + SYNC_PROVIDER, + synced, + selectionType as SelectableResourceType, + remoteId, + ); + } + return { result: synced, agentFilter: filtered, selectedKeys: keptKeys }; + }), + ); + + const narrowed = Boolean(agentFilter) || Object.keys(selectedKeys).length > 0; + const baseDir = dirname(out); + const removedFiles = flags.skipMissingFiles + ? removeMissingFileSources(result.config, baseDir) + : []; + const yamlContent = + narrowed || removedFiles.length > 0 + ? stringifyYaml(result.config, { lineWidth: 0 }) + : result.yaml; + await writeFile(out, yamlContent, "utf8"); + + const downloadedSkillFiles = await writeDownloadedSkillFiles(result, baseDir); + // Custom skills whose content could not be downloaded need local files + // before an apply would round-trip; surface them instead of prompting. + const missingSkillSources = collectMissingSkillSources(result.config, baseDir); + const secretEnvVars = (result.secretPlaceholders ?? []).map( + (placeholder) => placeholder.envVar, + ); + + if (format === "json") { + emitResult( + { + synced: out, + provider: SYNC_PROVIDER, + agent_id: flags.agentId, + selected_keys: Object.keys(selectedKeys).length > 0 ? selectedKeys : undefined, + counts: result.counts, + skill_files_downloaded: downloadedSkillFiles, + removed_files: removedFiles, + missing_skill_sources: missingSkillSources, + secret_env_vars: secretEnvVars, + unmatched_skill_refs: agentFilter?.unmatchedSkillIds, + }, + format, + ); + return; + } + + const countParts = Object.entries(result.counts).map(([type, count]) => `${count} ${type}(s)`); + emitBare( + `Synced ${countParts.length > 0 ? countParts.join(", ") : "0 resources"} from ${SYNC_PROVIDER} into ${out}.`, + ); + if (agentFilter) { + emitBare( + agentFilter.keptSkills + ? `Narrowed to agent ${flags.agentId} (kept ${agentFilter.keptSkills.length} referenced skill(s)).` + : `Narrowed to agent ${flags.agentId}.`, + ); + if (agentFilter.unmatchedSkillIds.length > 0) { + emitBare( + `Skill references without a matching skills entry (kept on the agent as-is): ${agentFilter.unmatchedSkillIds.join(", ")}.`, + ); + } + } + for (const [selectionType, keptKey] of Object.entries(selectedKeys)) { + const requestedId = resourceSelections[selectionType as SelectableResourceType]; + emitBare( + `Narrowed ${selectionType} to ${requestedId}${keptKey !== requestedId ? ` (key: ${keptKey})` : ""}.`, + ); + } + if (downloadedSkillFiles > 0) { + emitBare(`Downloaded ${downloadedSkillFiles} skill file(s) into ./skills/.`); + } + if (removedFiles.length > 0) { + emitBare( + `Removed ${removedFiles.length} file resource(s) with missing local sources: ${removedFiles.join(", ")}.`, + ); + } + if (missingSkillSources.length > 0) { + emitBare( + `Missing local skill sources (provide the files before apply): ${missingSkillSources.join(", ")}.`, + ); + } + if (secretEnvVars.length > 0) { + emitBare(`Set these env vars locally before apply: ${secretEnvVars.join(", ")}.`); + } + // `bl` prefix is safe: agent commands ship on `bl` only. + emitBare( + `Next: review ${out}, then run \`bl managed-agent migrate\` to merge it into agents.yaml.`, + ); + }, +}); + +/** + * Parse the --types list. Accepts singular and plural spellings ("skills" → + * "skill") and dedupes; invalid tokens are returned verbatim so validate() + * can reject them with the original user input. + */ +function parseSyncTypes(raw: string): { types: SyncableType[]; invalid: string[] } { + const types: SyncableType[] = []; + const invalid: string[] = []; + const tokens = raw + .split(",") + .map((token) => token.trim()) + .filter(Boolean); + for (const token of tokens) { + const singular = token.endsWith("s") ? token.slice(0, -1) : token; + const matched = SYNCABLE_TYPES.find( + (candidate) => candidate === token || candidate === singular, + ); + if (!matched) { + invalid.push(token); + } else if (!types.includes(matched)) { + types.push(matched); + } + } + return { types, invalid }; +} + +/** Result of narrowing a synced config to a single agent. */ +interface AgentFilterResult { + /** Undefined when skills narrowing was skipped (--skill-id takes over). */ + keptSkills?: string[]; + unmatchedSkillIds: string[]; +} + +/** + * Narrow a full synced config to a single agent: the agents group keeps only + * the entry whose yaml key equals the remote agent ID, and — unless an + * explicit --skill-id selection takes over (`narrowSkills: false`) — the + * skills group keeps only the custom skills that agent references (official + * skills live in the provider catalog and need no local entry). Skill + * references carry remote skill IDs while skills-group keys are + * metadata/display-name derived, so key misses are resolved through the + * custom skill catalog before being surfaced as unmatched (never silently + * dropped from the agent itself). Downloaded skill files and counts are + * re-scoped to what remains. + */ +async function filterConfigToAgent( + runtime: ProjectRuntimeContext, + result: SyncProjectResult, + agentId: string, + options: { narrowSkills: boolean }, +): Promise { + const config = result.config; + const agents = (config.agents ?? {}) as Record>; + const selected = agents[agentId]; + if (!selected) { + const available = Object.keys(agents); + throw new BailianError( + `Agent '${agentId}' not found on the remote workspace.`, + ExitCode.USAGE, + available.length > 0 + ? `Available agent IDs: ${available.join(", ")}.` + : "The workspace has no syncable (non-archived) agents.", + ); + } + config.agents = { [agentId]: selected }; + if ("agent" in result.counts) result.counts.agent = 1; + if (!options.narrowSkills) { + return { unmatchedSkillIds: [] }; + } + + const skillRefs = Array.isArray(selected.skills) + ? (selected.skills as Array>) + : []; + // Official skills resolve against the provider catalog at apply time; only + // custom references map to synced skills-group declarations. + const referencedIds = new Set( + skillRefs + .filter((skillRef) => skillRef.type !== "official") + .map((skillRef) => skillRef.skill_id) + .filter((skillId): skillId is string => typeof skillId === "string"), + ); + const skills = (config.skills ?? {}) as Record>; + const keptSkills = Object.keys(skills).filter((key) => referencedIds.has(key)); + const pendingIds = Array.from(referencedIds).filter((skillId) => !(skillId in skills)); + // Key misses: the ref carries a remote ID while the group key is a + // display-name slug — resolve through the custom catalog before declaring + // the reference unmatched. + const { resolved, unmatched: unmatchedSkillIds } = await resolveSkillKeysByIds( + runtime, + SYNC_PROVIDER, + skills, + pendingIds, + ); + for (const resolvedKey of resolved.values()) { + if (!keptSkills.includes(resolvedKey)) keptSkills.push(resolvedKey); + } + if (keptSkills.length > 0) { + config.skills = Object.fromEntries(keptSkills.map((key) => [key, skills[key]!])); + } else { + delete config.skills; + } + + if (result.skillFiles) { + const kept = new Set(keptSkills); + for (const skillName of result.skillFiles.keys()) { + if (!kept.has(skillName)) result.skillFiles.delete(skillName); + } + } + if ("skill" in result.counts) result.counts.skill = keptSkills.length; + + return { keptSkills, unmatchedSkillIds }; +} + +/** + * Drop file resources whose `source` does not exist locally — the remote + * platform cannot hand file content back, so keeping them would make the + * synced config un-appliable. Returns the removed YAML keys. + */ +function removeMissingFileSources(config: Record, baseDir: string): string[] { + const files = (config.files ?? {}) as Record>; + const removed = Object.entries(files) + .filter( + ([, decl]) => typeof decl.source === "string" && !existsSync(join(baseDir, decl.source)), + ) + .map(([key]) => key); + for (const key of removed) { + delete files[key]; + } + if (Object.keys(files).length === 0) { + delete config.files; + } + return removed; +} + +/** Persist provider-downloaded skill files under ./skills//; returns the file count. */ +async function writeDownloadedSkillFiles( + result: SyncProjectResult, + baseDir: string, +): Promise { + if (!result.skillFiles?.size) return 0; + let written = 0; + for (const [skillName, skillFileList] of result.skillFiles) { + for (const skillFile of skillFileList) { + const filePath = join(baseDir, "skills", skillName, skillFile.relativePath); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, skillFile.content); + written++; + } + } + return written; +} + +/** Custom skills whose local source dir/file is absent or empty after sync. */ +function collectMissingSkillSources(config: Record, baseDir: string): string[] { + const skills = (config.skills ?? {}) as Record>; + const missing: string[] = []; + for (const [key, decl] of Object.entries(skills)) { + if (decl.origin !== "custom" || typeof decl.source !== "string") continue; + const sourcePath = join(baseDir, decl.source); + if (!existsSync(sourcePath)) { + missing.push(key); + continue; + } + const sourceStat = statSync(sourcePath); + if (sourceStat.isDirectory() && readdirSync(sourcePath).length === 0) { + missing.push(key); + } + } + return missing; +} diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index cba8b32..e1c6afb 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -93,6 +93,8 @@ export { default as tokenPlanAssignSeats } from "./commands/token-plan/assign-se export { default as tokenPlanAddMember } from "./commands/token-plan/add-member.ts"; export { default as managedAgentInit } from "./commands/managed-agent/init.ts"; export { default as managedAgentValidate } from "./commands/managed-agent/validate.ts"; +export { default as managedAgentSync } from "./commands/managed-agent/sync.ts"; +export { default as managedAgentMigrate } from "./commands/managed-agent/migrate.ts"; 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"; diff --git a/packages/commands/tests/e2e/fixtures/managed-agent/agents-claude.yaml b/packages/commands/tests/e2e/fixtures/managed-agent/agents-claude.yaml new file mode 100644 index 0000000..80c9e41 --- /dev/null +++ b/packages/commands/tests/e2e/fixtures/managed-agent/agents-claude.yaml @@ -0,0 +1,15 @@ +version: "1" + +providers: + claude: + api_key: ${ANTHROPIC_API_KEY} + +defaults: + provider: claude + +agents: + assistant: + description: "E2E claude-target fixture" + model: claude-sonnet-4-6 + instructions: | + You are a helpful assistant. diff --git a/packages/commands/tests/e2e/fixtures/managed-agent/agents-synced.yaml b/packages/commands/tests/e2e/fixtures/managed-agent/agents-synced.yaml new file mode 100644 index 0000000..473411e --- /dev/null +++ b/packages/commands/tests/e2e/fixtures/managed-agent/agents-synced.yaml @@ -0,0 +1,30 @@ +version: "1" + +providers: + bailian: + api_key: ${DASHSCOPE_API_KEY} + base_url: ${BAILIAN_BASE_URL} + +environments: + dev: + config: + type: cloud + networking: + type: unrestricted + provider: bailian + +agents: + assistant: + description: "E2E synced fixture (already exists in target)" + model: qwen3.7-max + instructions: | + You are a helpful assistant. + environment: dev + provider: bailian + reviewer: + description: "E2E synced fixture (new agent)" + model: qwen3.7-max + instructions: | + You review code. + environment: dev + provider: bailian diff --git a/packages/commands/tests/e2e/managed-agent.e2e.test.ts b/packages/commands/tests/e2e/managed-agent.e2e.test.ts index ede46b7..a76a8e0 100644 --- a/packages/commands/tests/e2e/managed-agent.e2e.test.ts +++ b/packages/commands/tests/e2e/managed-agent.e2e.test.ts @@ -1,5 +1,6 @@ +import { join } from "node:path"; import { describe, expect, test } from "vite-plus/test"; -import { parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { e2eFixturesDir, parseStdoutJson, runCommandE2e } from "./helpers.ts"; import { MANAGED_AGENT_ROUTES } from "./topic-routes.ts"; /** @@ -245,3 +246,191 @@ describe("e2e: managed-agent(--dry-run 短路,不联网不写盘)", () => expect(data.remote_id).toBe("agent-e2e"); }); }); + +describe("e2e: managed-agent sync / migrate(bailian-only)", () => { + const fixturesDir = join(e2eFixturesDir, "managed-agent"); + const agentsYaml = join(fixturesDir, "agents.yaml"); + const agentsSyncedYaml = join(fixturesDir, "agents-synced.yaml"); + const agentsClaudeYaml = join(fixturesDir, "agents-claude.yaml"); + + test("managed-agent sync --help 正常退出", async () => { + const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + "managed-agent", + "sync", + "--help", + ]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--out|--force|--skip-missing-files/i); + expect(stderr).toMatch(/--agent-id/i); + expect(stderr).toMatch(/--types/i); + expect(stderr).toMatch(/--environment-id/i); + expect(stderr).toMatch(/--vault-id/i); + expect(stderr).toMatch(/--file-id/i); + expect(stderr).toMatch(/--skill-id/i); + }); + + test("sync 非法 --types 时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + "managed-agent", + "sync", + "--types", + "agent,sessions", + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--types contains unsupported values: sessions/i); + }); + + test("sync --agent-id 搭配不含 agent 的 --types 时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + "managed-agent", + "sync", + "--agent-id", + "agent-e2e", + "--types", + "skill", + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--agent-id requires --types to include agent/i); + }); + + test("sync --vault-id 搭配不含 vault 的 --types 时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + "managed-agent", + "sync", + "--vault-id", + "vault-e2e", + "--types", + "agent", + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--vault-id requires --types to include vault/i); + }); + + test("sync 输出文件已存在且未 --force 时退出为用法错误 (2)", async () => { + // auth: "apiKey" 的凭证解析先于 run() 执行;注入假 key 让用例不依赖环境凭证, + // 命令仍会在覆盖写守卫处短路(先于构建 SDK runtime),不产生任何网络请求。 + const { stderr, exitCode } = await runCommandE2e( + MANAGED_AGENT_ROUTES, + ["managed-agent", "sync", "--out", agentsYaml, "--quiet"], + { DASHSCOPE_API_KEY: "sk-e2e-sync" }, + ); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/already exists/i); + }); + + test("sync --dry-run 仅回显计划,即使 --out 已存在也不报错", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + "managed-agent", + "sync", + "--dry-run", + "--agent-id", + "agent-e2e", + "--skill-id", + "skill-e2e", + "--types", + "agents,skills", + "--out", + agentsYaml, + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + would_sync?: { + provider?: string; + out?: string; + agent_id?: string; + skill_id?: string; + types?: string[]; + }; + }>(stdout); + expect(data.would_sync?.provider).toBe("bailian"); + expect(data.would_sync?.out).toBe(agentsYaml); + expect(data.would_sync?.agent_id).toBe("agent-e2e"); + expect(data.would_sync?.skill_id).toBe("skill-e2e"); + // 复数拼写归一化为 SDK 的单数资源类型 + expect(data.would_sync?.types).toEqual(["agent", "skill"]); + }); + + test("sync --agent-id 时 --types 自动补充 skill(关联技能联动导出)", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + "managed-agent", + "sync", + "--dry-run", + "--agent-id", + "agent-e2e", + "--types", + "agent", + "--out", + "agents.synced.e2e-missing.yaml", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ would_sync?: { types?: string[] } }>(stdout); + expect(data.would_sync?.types).toEqual(["agent", "skill"]); + }); + + test("managed-agent migrate --help 正常退出", async () => { + const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + "managed-agent", + "migrate", + "--help", + ]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--from|--to/i); + }); + + test("migrate 目标文件缺失时退出为用法错误 (2)", async () => { + // auth: "apiKey" 的凭证解析先于 run() 执行;注入假 key 让用例不依赖环境凭证, + // 命令仍会在目标文件守卫处短路,不产生任何网络请求。 + const { stderr, exitCode } = await runCommandE2e( + MANAGED_AGENT_ROUTES, + [ + "managed-agent", + "migrate", + "--from", + agentsSyncedYaml, + "--to", + "agents.e2e-missing.yaml", + "--quiet", + ], + { DASHSCOPE_API_KEY: "sk-e2e-migrate" }, + ); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/Target file .*agents\.e2e-missing\.yaml.*not found/i); + }); + + test("migrate 目标 provider 非 bailian 时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCommandE2e( + MANAGED_AGENT_ROUTES, + ["managed-agent", "migrate", "--from", agentsSyncedYaml, "--to", agentsClaudeYaml, "--quiet"], + { DASHSCOPE_API_KEY: "sk-e2e-migrate" }, + ); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/only targets the bailian provider/i); + }); + + test("migrate --dry-run 仅回显计划,目标缺失也不报错", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + "managed-agent", + "migrate", + "--dry-run", + "--from", + agentsSyncedYaml, + "--to", + "agents.e2e-missing.yaml", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + would_migrate?: { from?: string; to?: string }; + }>(stdout); + expect(data.would_migrate?.from).toBe(agentsSyncedYaml); + expect(data.would_migrate?.to).toBe("agents.e2e-missing.yaml"); + }); +}); diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index 3549784..0b07875 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -170,6 +170,8 @@ export const SKILL_ROUTES: E2eRouteExports = { export const MANAGED_AGENT_ROUTES: E2eRouteExports = { "managed-agent init": "managedAgentInit", "managed-agent validate": "managedAgentValidate", + "managed-agent sync": "managedAgentSync", + "managed-agent migrate": "managedAgentMigrate", "managed-agent plan": "managedAgentPlan", "managed-agent apply": "managedAgentApply", "managed-agent destroy": "managedAgentDestroy", diff --git a/skills/bailian-managed-agent/reference/index.md b/skills/bailian-managed-agent/reference/index.md index 24067d2..b84d7dc 100644 --- a/skills/bailian-managed-agent/reference/index.md +++ b/skills/bailian-managed-agent/reference/index.md @@ -14,6 +14,7 @@ Use this index for the skill-scoped quick index and global flags. | `bl managed-agent apply` | Apply planned changes to create/update/delete agent resources | [managed-agent.md](managed-agent.md) | | `bl managed-agent destroy` | Destroy all managed agent resources tracked in state | [managed-agent.md](managed-agent.md) | | `bl managed-agent init` | Create a new agents.yaml template | [managed-agent.md](managed-agent.md) | +| `bl managed-agent migrate` | Merge a synced config into a bailian agents.yaml | [managed-agent.md](managed-agent.md) | | `bl managed-agent plan` | Show what changes would be applied to agent infrastructure | [managed-agent.md](managed-agent.md) | | `bl managed-agent session create` | Create a new session for an agent | [managed-agent.md](managed-agent.md) | | `bl managed-agent session delete` | Delete a session | [managed-agent.md](managed-agent.md) | @@ -27,13 +28,14 @@ Use this index for the skill-scoped quick index and global flags. | `bl managed-agent state list` | List resources tracked in agents state | [managed-agent.md](managed-agent.md) | | `bl managed-agent state rm` | Remove a resource from state without destroying it remotely | [managed-agent.md](managed-agent.md) | | `bl managed-agent state show` | Show details of a resource in agents state | [managed-agent.md](managed-agent.md) | +| `bl managed-agent sync` | Export remote bailian resources into a local synced config | [managed-agent.md](managed-agent.md) | | `bl managed-agent validate` | Validate an agents.yaml configuration (offline) | [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`, `migrate`, `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`, `sync`, `validate` | [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 db5e29e..48bde8f 100644 --- a/skills/bailian-managed-agent/reference/managed-agent.md +++ b/skills/bailian-managed-agent/reference/managed-agent.md @@ -12,6 +12,7 @@ Index: [index.md](index.md) | `bl managed-agent apply` | Apply planned changes to create/update/delete agent resources | | `bl managed-agent destroy` | Destroy all managed agent resources tracked in state | | `bl managed-agent init` | Create a new agents.yaml template | +| `bl managed-agent migrate` | Merge a synced config into a bailian agents.yaml | | `bl managed-agent plan` | Show what changes would be applied to agent infrastructure | | `bl managed-agent session create` | Create a new session for an agent | | `bl managed-agent session delete` | Delete a session | @@ -25,6 +26,7 @@ Index: [index.md](index.md) | `bl managed-agent state list` | List resources tracked in agents state | | `bl managed-agent state rm` | Remove a resource from state without destroying it remotely | | `bl managed-agent state show` | Show details of a resource in agents state | +| `bl managed-agent sync` | Export remote bailian resources into a local synced config | | `bl managed-agent validate` | Validate an agents.yaml configuration (offline) | ## Command details @@ -130,6 +132,40 @@ bl managed-agent init --provider bailian --agent-name assistant bl managed-agent init --provider all ``` +### `bl managed-agent migrate` + +| Field | Value | +| --------------- | -------------------------------------------------------- | +| **Name** | `managed-agent migrate` | +| **Description** | Merge a synced config into a bailian agents.yaml | +| **Usage** | `bl managed-agent migrate [--from ] [--to ]` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------ | ------ | -------- | ----------------------------------------------------------- | +| `--from ` | string | no | Synced config to migrate from (default: agents.synced.yaml) | +| `--to ` | string | no | Target agents.yaml to merge into (default: agents.yaml) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- The merge itself runs against local files; bl's unified apiKey gate still applies — login via `bl auth login`, pass --api-key, or set DASHSCOPE_API_KEY. +- Only a bailian-target agents.yaml is supported: migrated resources are re-pointed to provider bailian, with models/tools/environments normalized to Bailian-supported values. +- Resources whose YAML key already exists in the target are skipped, never overwritten. +- Run `bl managed-agent plan` afterwards to review the merged config before apply. + +#### Examples + +```bash +bl managed-agent migrate +``` + +```bash +bl managed-agent migrate --from agents.synced.yaml --to agents.yaml +``` + ### `bl managed-agent plan` | Field | Value | @@ -574,6 +610,67 @@ bl managed-agent state rm --address bailian.agent.assistant bl managed-agent state show --address bailian.agent.assistant ``` +### `bl managed-agent sync` + +| Field | Value | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `managed-agent sync` | +| **Description** | Export remote bailian resources into a local synced config | +| **Usage** | `bl managed-agent sync [--types ] [--agent-id\|--environment-id\|--vault-id\|--file-id\|--skill-id ] [--file ] [--out ] [--force] [--skip-missing-files]` | + +#### Flags + +| Flag | Type | Required | Description | +| ----------------------- | ------ | -------- | --------------------------------------------------------------------------------------------- | +| `--file ` | string | no | Config file path (default: agents.yaml) | +| `--out ` | string | no | Output path for the synced config (default: agents.synced.yaml) | +| `--types ` | string | no | Comma-separated resource types to sync: environment, vault, file, skill, agent (default: all) | +| `--agent-id ` | string | no | Sync a single agent by its remote ID (plus the skills it references) | +| `--environment-id ` | string | no | Sync a single environment by its remote ID | +| `--vault-id ` | string | no | Sync a single vault by its remote ID | +| `--file-id ` | string | no | Sync a single file resource by its remote ID | +| `--skill-id ` | string | no | Sync a single skill by its remote ID (overrides --agent-id skill narrowing) | +| `--force` | switch | no | Overwrite an existing output file | +| `--skip-missing-files` | switch | no | Drop file resources whose local source is missing instead of keeping them | +| `--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. +- Syncs from the bailian provider only: remote AgentStudio resources (environments, vaults, files, skills, agents) are exported into a local synced config for review. +- --types narrows the export to the listed resource types (plural spellings accepted); sessions are runtime instances, not syncable resources. +- With --agent-id, the agents group keeps only that agent (yaml key = remote agent ID) and its referenced custom skills are synced along — the skill group is exported even when --types omits it; official skill references need no local entry. +- Bailian binds environments/vaults/files at the session level, not on the agent, so --agent-id keeps those groups as shared infrastructure. +- Each --environment-id/--vault-id/--file-id/--skill-id narrows its own resource group to the single remote resource (yaml key resolved from the remote listing); combine with --types for a minimal output. +- Requires an agents.yaml with a bailian provider block — run `bl managed-agent init` first. +- Secrets are never exported: vault credentials keep ${ENV} placeholders; set those env vars locally before apply. +- Merge the synced config into agents.yaml with `bl managed-agent migrate`. + +#### Examples + +```bash +bl managed-agent sync +``` + +```bash +bl managed-agent sync --types agent,skill +``` + +```bash +bl managed-agent sync --agent-id agent-abc123 +``` + +```bash +bl managed-agent sync --skill-id skill-xyz --types skill +``` + +```bash +bl managed-agent sync --force --skip-missing-files +``` + ### `bl managed-agent validate` | Field | Value |