Compare commits

...

1 Commits

Author SHA1 Message Date
chenanran555 ac48c3ec23 faet(agent): add sync and migrate commands 2026-08-10 20:55:02 +08:00
11 changed files with 1154 additions and 4 deletions
+4
View File
@@ -95,6 +95,8 @@ import {
skillList,
managedAgentInit,
managedAgentValidate,
managedAgentSync,
managedAgentMigrate,
managedAgentPlan,
managedAgentApply,
managedAgentDestroy,
@@ -213,6 +215,8 @@ export const commands: Record<string, AnyCommand> = {
"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,
@@ -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<SyncableType, "agent">;
/** Maps a syncable type to its top-level group key in the synced config. */
const GROUP_KEY: Record<SyncableType, string> = {
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<string> {
const groupKey = GROUP_KEY[type];
const group = (result.config[groupKey] ?? {}) as Record<string, Record<string, unknown>>;
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<LocatedRemote> {
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<string, Record<string, unknown>>,
remoteIds: string[],
): Promise<{ resolved: Map<string, string>; unmatched: string[] }> {
const resolved = new Map<string, string>();
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<string, Record<string, unknown>>,
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;
}
@@ -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: "<path>",
description: "Synced config to migrate from (default: agents.synced.yaml)",
},
to: {
type: "string",
valueHint: "<path>",
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 <path>] [--to <path>]",
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<void> {
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<string, unknown>;
const defaults = config.defaults as Record<string, unknown> | undefined;
const providers = config.providers as Record<string, unknown> | 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}'.`,
);
}
}
@@ -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: "<path>",
description: "Config file path (default: agents.yaml)",
},
out: {
type: "string",
valueHint: "<path>",
description: `Output path for the synced config (default: ${DEFAULT_SYNC_OUTPUT})`,
},
types: {
type: "string",
valueHint: "<list>",
description: `Comma-separated resource types to sync: ${SYNCABLE_TYPES.join(", ")} (default: all)`,
},
agentId: {
type: "string",
valueHint: "<id>",
description: "Sync a single agent by its remote ID (plus the skills it references)",
},
environmentId: {
type: "string",
valueHint: "<id>",
description: "Sync a single environment by its remote ID",
},
vaultId: {
type: "string",
valueHint: "<id>",
description: "Sync a single vault by its remote ID",
},
fileId: {
type: "string",
valueHint: "<id>",
description: "Sync a single file resource by its remote ID",
},
skillId: {
type: "string",
valueHint: "<id>",
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 <list>] [--agent-id|--environment-id|--vault-id|--file-id|--skill-id <id>] [--file <path>] [--out <path>] [--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<SyncableType, string | undefined> = {
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<Record<SelectableResourceType, string>> = {
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<Record<SelectableResourceType, string>> = {};
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<AgentFilterResult> {
const config = result.config;
const agents = (config.agents ?? {}) as Record<string, Record<string, unknown>>;
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<Record<string, unknown>>)
: [];
// 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<string, Record<string, unknown>>;
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<string, unknown>, baseDir: string): string[] {
const files = (config.files ?? {}) as Record<string, Record<string, unknown>>;
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/<name>/; returns the file count. */
async function writeDownloadedSkillFiles(
result: SyncProjectResult,
baseDir: string,
): Promise<number> {
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<string, unknown>, baseDir: string): string[] {
const skills = (config.skills ?? {}) as Record<string, Record<string, unknown>>;
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;
}
+2
View File
@@ -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";
@@ -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.
@@ -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
@@ -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 / migratebailian-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");
});
});
@@ -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",
@@ -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
@@ -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 <path>] [--to <path>]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------ | ------ | -------- | ----------------------------------------------------------- |
| `--from <path>` | string | no | Synced config to migrate from (default: agents.synced.yaml) |
| `--to <path>` | string | no | Target agents.yaml to merge into (default: agents.yaml) |
| `--api-key <key>` | string | no | API key |
| `--base-url <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 <list>] [--agent-id\|--environment-id\|--vault-id\|--file-id\|--skill-id <id>] [--file <path>] [--out <path>] [--force] [--skip-missing-files]` |
#### Flags
| Flag | Type | Required | Description |
| ----------------------- | ------ | -------- | --------------------------------------------------------------------------------------------- |
| `--file <path>` | string | no | Config file path (default: agents.yaml) |
| `--out <path>` | string | no | Output path for the synced config (default: agents.synced.yaml) |
| `--types <list>` | string | no | Comma-separated resource types to sync: environment, vault, file, skill, agent (default: all) |
| `--agent-id <id>` | string | no | Sync a single agent by its remote ID (plus the skills it references) |
| `--environment-id <id>` | string | no | Sync a single environment by its remote ID |
| `--vault-id <id>` | string | no | Sync a single vault by its remote ID |
| `--file-id <id>` | string | no | Sync a single file resource by its remote ID |
| `--skill-id <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 <key>` | string | no | API key |
| `--base-url <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 |