From aa1484264e3da4735a4e785b919a11bf58759601 Mon Sep 17 00:00:00 2001 From: chenanran555 Date: Tue, 8 Sep 2026 14:46:56 +0800 Subject: [PATCH] fix(managed-agent): improve project initialization and build validation --- docs/agents/command-flag-change.md | 1 + .../src/commands/managed-agent/project.ts | 62 ++++- .../managed-agent-project-init.e2e.test.ts | 247 ++++++++++++++++++ packages/core/src/types/command.ts | 4 +- packages/runtime/src/create-cli.ts | 2 +- .../reference/managed-agent.md | 44 ++-- 6 files changed, 325 insertions(+), 35 deletions(-) create mode 100644 packages/commands/tests/e2e/managed-agent-project-init.e2e.test.ts diff --git a/docs/agents/command-flag-change.md b/docs/agents/command-flag-change.md index d65805a..a8ff653 100644 --- a/docs/agents/command-flag-change.md +++ b/docs/agents/command-flag-change.md @@ -19,6 +19,7 @@ - 类型由 `ParsedFlags` 推导;避免手写 `flags.x as number` 这类断言 - 单 flag 必填用 `required: true`;跨 flag / 值相关校验放 `validate` - 默认值 fallback 写在命令实现或 `Settings` 解析层,不要重复解析 env/config + - 需要在高风险确认前检查本地路径时,可用异步 `validate`;runtime 会在鉴权和确认前等待它完成。这里只允许本地只读检查,不写文件、不请求远端。非缺参的环境错误应抛出 `BailianError`,避免裸命令调用被当成缺参而仅显示 help。 ### B. 鉴权 / 全局选项 diff --git a/packages/commands/src/commands/managed-agent/project.ts b/packages/commands/src/commands/managed-agent/project.ts index 50330c4..452c6b0 100644 --- a/packages/commands/src/commands/managed-agent/project.ts +++ b/packages/commands/src/commands/managed-agent/project.ts @@ -1,3 +1,5 @@ +import { stat } from "node:fs/promises"; +import { join } from "node:path"; import { BailianError, defineCommand, @@ -14,6 +16,7 @@ import { planProjectPublish, previewProjectBuild, type ProjectBuildResolver, + resolveDirectoryProjectRoot, validateDirectoryProject, } from "@openagentpack/sdk/project-workspace"; import { CREDENTIALS_NOTE, resolveAgentProjectConfig } from "./_engine/config-loader.ts"; @@ -44,9 +47,23 @@ export const managedAgentProjectInit = defineCommand({ }, auth: "none", usageArgs: "[--project ]", - flags: PROJECT_FLAG, - exampleArgs: ["", "--project ./my-agent"], + flags: { + project: { + ...PROJECT_FLAG.project, + description: { + "en-US": "Directory project root (default: ./managed-agent under the current directory)", + "zh-CN": "目录项目根路径(默认:当前目录下的 ./managed-agent)", + }, + }, + }, + exampleArgs: ["", "--project ./my-agent", "--project ."], notes: [ + { + "en-US": + "Without --project, creates a managed-agent/ subdirectory. Enter it before running other project commands. Use --project . to initialize in place or convert the current agents.yaml; existing project files are not overwritten.", + "zh-CN": + "不传 --project 时创建 managed-agent/ 子目录;后续项目操作请先进入该目录。使用 --project . 可在当前目录初始化或转换 agents.yaml;不会覆盖已有项目文件。", + }, { "en-US": "New projects include Skill, File, Vault, and Environment examples under each resource directory's _examples/. They are not referenced by agent.json and are excluded from Build/Publish. Copy an example outside _examples/ to enable it, then configure its Agent reference.", @@ -55,16 +72,17 @@ export const managedAgentProjectInit = defineCommand({ }, ], async run(ctx) { + const projectRoot = ctx.flags.project ?? "./managed-agent"; if (ctx.settings.dryRun) { emitResult( { - would_initialize_project: ctx.flags.project ?? ".", + would_initialize_project: projectRoot, }, detectOutputFormat(ctx.settings.output), ); return; } - const result = await initializeDirectoryProject({ projectRoot: ctx.flags.project ?? "." }); + const result = await initializeDirectoryProject({ projectRoot }); emitResult(result, detectOutputFormat(ctx.settings.output)); }, }); @@ -99,18 +117,34 @@ export const managedAgentProjectBuild = defineCommand({ "zh-CN": "整理目录源文件并生成不可变的发布 Build", }, auth: "none", - risk: { - level: "high", - message: { + notes: [ + { "en-US": - "This organizes project source, moves literal Vault secrets into the local .env, and writes the previewed immutable Build.", + "Build writes local project files without confirmation, including inferred resource associations and migration of plaintext Vault secrets into .env. Use --dry-run to preview without writing. Publish still requires explicit confirmation before remote changes.", "zh-CN": - "该操作会整理项目源文件,将 Vault 明文密钥移入本地 .env,并写入已预览的不可变 Build。", + "Build 无需确认即可写入本地项目文件,包括推断的资源关联及将 Vault 明文密钥移入 .env。使用 --dry-run 可只预览不写入。Publish 变更远端资源前仍需显式确认。", }, - }, + ], usageArgs: "[--project ]", flags: PROJECT_FLAG, - exampleArgs: ["--dry-run", "--yes", "--project ./my-agent --yes"], + exampleArgs: ["", "--dry-run", "--project ./my-agent"], + async validate(flags) { + await withAgentErrors(async () => { + const root = await resolveDirectoryProjectRoot(flags.project ?? "."); + const metadata = await stat(join(root, "project.json")).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + }); + if (!metadata?.isFile()) { + throw new BailianError( + `Not a project root: ${root} (project.json is missing).`, + ExitCode.USAGE, + "Run from the directory containing project.json, or pass --project .", + ); + } + }); + return undefined; + }, async run(ctx) { const root = ctx.flags.project ?? "."; const preview = await previewProjectBuild(root); @@ -120,7 +154,11 @@ export const managedAgentProjectBuild = defineCommand({ return; } if (!preview.can_build) - throw new BailianError("Directory project is invalid and cannot be built.", ExitCode.GENERAL); + throw new BailianError( + preview.diagnostics.find((diagnostic) => diagnostic.severity === "error")?.message ?? + "Directory project is invalid and cannot be built.", + ExitCode.GENERAL, + ); const built = await commitProjectBuild({ projectRoot: root, baseRevision: preview.project_revision, diff --git a/packages/commands/tests/e2e/managed-agent-project-init.e2e.test.ts b/packages/commands/tests/e2e/managed-agent-project-init.e2e.test.ts new file mode 100644 index 0000000..855e3df --- /dev/null +++ b/packages/commands/tests/e2e/managed-agent-project-init.e2e.test.ts @@ -0,0 +1,247 @@ +import { + cp, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runNodeMain } from "e2e/runner"; +import { afterEach, describe, expect, test } from "vite-plus/test"; +import { parseStdoutJson, runCommandHelp } from "./helpers.ts"; + +const routes = { + "managed-agent project init": "managedAgentProjectInit", + "managed-agent project build": "managedAgentProjectBuild", + "managed-agent project publish": "managedAgentProjectPublish", + "managed-agent project validate": "managedAgentProjectValidate", +} as const; +const directories: string[] = []; + +async function temporaryDirectory() { + const directory = await realpath(await mkdtemp(join(tmpdir(), "bailian-project-init-"))); + directories.push(directory); + return directory; +} + +afterEach(async () => { + for (const directory of directories.splice(0)) { + await rm(directory, { recursive: true, force: true }); + } +}); + +function runInit(directory: string, args: string[] = []) { + return runProject(directory, "init", args); +} + +async function runProject(directory: string, subcommand: string, args: string[] = [], json = true) { + const configRoot = await temporaryDirectory(); + return runNodeMain( + fileURLToPath(new URL("./harness/main.ts", import.meta.url)), + ["managed-agent", "project", subcommand, ...args, ...(json ? ["--output", "json"] : [])], + { + cwd: directory, + env: { + BAILIAN_CONFIG_DIR: configRoot, + BAILIAN_E2E_ROUTES: JSON.stringify( + Object.entries(routes).map(([path, exportName]) => ({ path, export: exportName })), + ), + }, + }, + ); +} + +describe("e2e: managed-agent project init directory defaults", () => { + test("build links copied resources and reports ambiguous environment bindings without writing", async () => { + const directory = await temporaryDirectory(); + const initialized = await runInit(directory); + expect(initialized.exitCode, initialized.stderr).toBe(0); + const root = join(directory, "managed-agent"); + for (const [section, id] of [ + ["skills", "example-skill"], + ["files", "example-file"], + ["environments", "example-env"], + ["vaults", "example-vault"], + ] as const) { + await cp( + join(root, "agents/assistant", section, "_examples", id), + join(root, "agents/assistant", section, id), + { recursive: true }, + ); + } + const agentPath = join(root, "agents/assistant/agent.json"); + const original = await readFile(agentPath, "utf8"); + const preview = await runProject(root, "build", ["--dry-run"]); + expect(preview.exitCode, preview.stderr).toBe(0); + expect(parseStdoutJson<{ can_build: boolean }>(preview.stdout).can_build).toBe(true); + expect(await readFile(agentPath, "utf8")).toBe(original); + const built = await runProject(root, "build"); + expect(built.exitCode, built.stderr).toBe(0); + const agent = JSON.parse(await readFile(agentPath, "utf8")); + expect(agent).toMatchObject({ + environment: "example-env", + vault: "example-vault", + skills: ["example-skill"], + files: [{ file: "example-file", mount_path: "/mnt/example.md" }], + }); + const alternatePath = join(root, "agents/assistant/environments/alternate"); + await mkdir(alternatePath); + await writeFile( + join(alternatePath, "environment.json"), + JSON.stringify({ id: "alternate", config: { type: "cloud" } }), + ); + delete agent.environment; + await writeFile(agentPath, JSON.stringify(agent)); + const beforeConflict = await readFile(agentPath, "utf8"); + const buildPath = join(root, ".openagentpack/build/agents.yaml"); + const beforeBuild = await readFile(buildPath, "utf8"); + for (const json of [false, true]) { + const conflict = await runProject(root, "build", [], json); + expect(conflict.exitCode).toBe(1); + expect(conflict.stderr).toContain("multiple local environment resources"); + expect(conflict.stderr).toContain("Set 'environment' explicitly"); + expect(conflict.stderr).not.toMatch(/\p{Script=Han}/u); + } + expect(await readFile(agentPath, "utf8")).toBe(beforeConflict); + expect(await readFile(buildPath, "utf8")).toBe(beforeBuild); + }); + + test("build checks directories and writes without confirmation while publish stays gated", async () => { + const directory = await temporaryDirectory(); + const initialized = await runInit(directory); + expect(initialized.exitCode, initialized.stderr).toBe(0); + const root = join(directory, "managed-agent"); + const nested = join(root, "agents/assistant/skills"); + for (const args of [[], ["--dry-run"]]) { + const result = await runProject(nested, "build", args, false); + expect(result.exitCode, result.stderr).toBe(2); + expect(result.stderr).toContain("Not a project root:"); + expect(result.stderr).not.toMatch(/\p{Script=Han}/u); + expect(result.stderr).toContain(`cd '${root}'`); + expect(result.stderr).not.toContain("high-risk"); + expect(result.stderr).not.toContain("Usage:"); + } + const explicit = await runProject(root, "build", ["--project", nested]); + expect(explicit.exitCode).toBe(2); + expect(explicit.stderr).toContain(`--project '${root}'`); + const noMarker = await runProject(directory, "build", [], false); + expect(noMarker.exitCode).toBe(2); + expect(noMarker.stderr).toContain("project.json"); + expect(noMarker.stderr).not.toMatch(/\p{Script=Han}/u); + expect(noMarker.stderr).not.toContain("high-risk"); + expect(await stat(join(nested, ".openagentpack")).catch(() => null)).toBeNull(); + + const preview = await runProject(root, "build", ["--dry-run"]); + expect(preview.exitCode, preview.stderr).toBe(0); + expect(await stat(join(root, ".openagentpack/build")).catch(() => null)).toBeNull(); + const built = await runProject(root, "build"); + expect(built.exitCode, built.stderr).toBe(0); + expect(built.stderr).not.toContain("requires_confirmation"); + expect((await stat(join(root, ".openagentpack/build/agents.yaml"))).isFile()).toBe(true); + const explicitValid = await runProject(nested, "build", ["--project", root]); + expect(explicitValid.exitCode, explicitValid.stderr).toBe(0); + const storePath = join(root, ".openagentpack/versions/project/store.json"); + const beforePublish = await readFile(storePath, "utf8"); + const publish = await runProject(root, "publish"); + expect(publish.exitCode).toBe(7); + expect(publish.stderr).toContain("requires_confirmation"); + expect(await readFile(storePath, "utf8")).toBe(beforePublish); + }); + + test("only publish help includes confirmation; build keeps dry-run", async () => { + const build = await runCommandHelp(routes, ["managed-agent", "project", "build", "--help"]); + expect(build.stderr).not.toContain("--yes"); + expect(build.stderr).toContain("--dry-run"); + const publish = await runCommandHelp(routes, ["managed-agent", "project", "publish", "--help"]); + expect(publish.stderr).toContain("--yes"); + const directory = await temporaryDirectory(); + const removedFlag = await runProject(directory, "build", ["--yes"]); + expect(removedFlag.exitCode).toBe(2); + expect(removedFlag.stderr).toMatch(/Unknown flag.*--yes/); + }); + + test("nested build and validate explain the project root without changing directories", async () => { + const directory = await temporaryDirectory(); + const initialized = await runInit(directory); + expect(initialized.exitCode, initialized.stderr).toBe(0); + const root = join(directory, "managed-agent"); + const nested = join(root, "agents/assistant/skills"); + for (const command of ["build", "validate"]) { + const result = await runProject(nested, command, ["--dry-run"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Not a project root:"); + expect(result.stderr).not.toMatch(/\p{Script=Han}/u); + expect(result.stderr).toContain(`cd '${root}'`); + expect(result.stderr).toContain(`--project '${root}'`); + expect(result.stderr).not.toContain("ERR_MODULE_NOT_FOUND"); + } + expect(await stat(join(nested, ".openagentpack")).catch(() => null)).toBeNull(); + const corrected = await runProject(nested, "build", ["--project", root, "--dry-run"]); + expect(corrected.exitCode, corrected.stderr).toBe(0); + }); + + test("help describes the subdirectory default and explicit in-place initialization", async () => { + const result = await runCommandHelp(routes, ["managed-agent", "project", "init", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("./managed-agent"); + expect(result.stderr).toContain("--project ."); + }); + + test("default dry-run reports the child directory without creating it", async () => { + const directory = await temporaryDirectory(); + const result = await runInit(directory, ["--dry-run"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseStdoutJson(result.stdout)).toEqual({ would_initialize_project: "./managed-agent" }); + expect(await stat(join(directory, "managed-agent")).catch(() => null)).toBeNull(); + }); + + test("initializes only the child, ignores parent source, and rejects repeated init without overwriting", async () => { + const directory = await temporaryDirectory(); + const parentYaml = "not a valid project declaration"; + await writeFile(join(directory, "agents.yaml"), parentYaml); + if (process.platform !== "win32") { + await symlink("missing-instructions.md", join(directory, "CLAUDE.md")); + } + const result = await runInit(directory); + expect(result.exitCode, result.stderr).toBe(0); + const projectRoot = join(directory, "managed-agent"); + const initialized = parseStdoutJson<{ + project_root: string; + baseline_version: string; + converted_from_yaml: boolean; + }>(result.stdout); + expect(initialized.project_root).toBe(projectRoot); + expect(initialized.baseline_version).toHaveLength(64); + expect(initialized.converted_from_yaml).toBe(false); + expect(await stat(join(directory, "project.json")).catch(() => null)).toBeNull(); + expect(await readFile(join(directory, "agents.yaml"), "utf8")).toBe(parentYaml); + expect((await stat(join(projectRoot, "agents/assistant/agent.json"))).isFile()).toBe(true); + expect( + (await stat(join(projectRoot, ".openagentpack/versions/project/store.json"))).isFile(), + ).toBe(true); + const instructions = join(projectRoot, "agents/assistant/instructions.md"); + await writeFile(instructions, "user changes"); + const repeated = await runInit(directory); + expect(repeated.exitCode).not.toBe(0); + expect(repeated.stderr).toContain("already exists"); + expect(await readFile(instructions, "utf8")).toBe("user changes"); + }); + + test("explicit paths, including dot, remain exact project roots", async () => { + for (const target of ["custom-agent", "."]) { + const directory = await temporaryDirectory(); + const result = await runInit(directory, ["--project", target]); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseStdoutJson<{ project_root: string }>(result.stdout).project_root).toBe( + join(directory, target), + ); + expect(await stat(join(directory, "managed-agent")).catch(() => null)).toBeNull(); + } + }); +}); diff --git a/packages/core/src/types/command.ts b/packages/core/src/types/command.ts index 66b6268..0a19de9 100644 --- a/packages/core/src/types/command.ts +++ b/packages/core/src/types/command.ts @@ -287,8 +287,10 @@ export interface Command { * Cross-flag validation, after parsing and before run. Return an error message * → UsageError; undefined to pass. Single-flag `required` is enforced by the * parser — use this for rules spanning flags or depending on a flag's *value*. + * May be async for read-only local preflight. Runtime awaits it before auth + * and confirmation. Do not perform remote requests or local writes here. */ - validate?: (flags: ParsedFlags) => string | undefined; + validate?: (flags: ParsedFlags) => string | undefined | Promise; run: (ctx: CommandContext) => Promise; } diff --git a/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index 1cf98e3..3a7f5df 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -217,7 +217,7 @@ export function createCli(commands: Record, opts: CliOptions parsedFlags, Object.keys(res.command.flags ?? {}), ) as ParsedFlags; - const invalid = res.command.validate?.(ownFlags); + const invalid = await res.command.validate?.(ownFlags); if (invalid) throw new UsageError(invalid); // 校验通过 → 建源、解析 settings、组 ctx,进中间件执行命令。 diff --git a/skills/bailian-managed-agent/reference/managed-agent.md b/skills/bailian-managed-agent/reference/managed-agent.md index a933746..da7a1dd 100644 --- a/skills/bailian-managed-agent/reference/managed-agent.md +++ b/skills/bailian-managed-agent/reference/managed-agent.md @@ -1181,38 +1181,35 @@ bl managed-agent playground --file agents.yaml --no-open ### `bl managed-agent project build` -| Field | Value | -| ------------------ | ------------------------------------------------------------------------------------------------------------------------- | -| **Name** | `managed-agent project build` | -| **Description** | Organize directory source and generate the immutable Publish Build | -| **Authentication** | No Auth | -| **Usage** | `bl managed-agent project build [--project ]` | -| **Risk** | `high` | -| **Risk message** | This organizes project source, moves literal Vault secrets into the local .env, and writes the previewed immutable Build. | - -> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. +| Field | Value | +| ------------------ | ------------------------------------------------------------------ | +| **Name** | `managed-agent project build` | +| **Description** | Organize directory source and generate the immutable Publish Build | +| **Authentication** | No Auth | +| **Usage** | `bl managed-agent project build [--project ]` | #### Flags | Flag | Type | Required | Description | | ----------------------- | ------ | -------- | --------------------------------------------------- | | `--project ` | string | no | Directory project root (default: current directory) | -| `--yes` | switch | no | Confirm this high-risk operation | + +#### Notes + +- Build writes local project files without confirmation, including inferred resource associations and migration of plaintext Vault secrets into .env. Use --dry-run to preview without writing. Publish still requires explicit confirmation before remote changes. #### Examples +```bash +bl managed-agent project build +``` + ```bash bl managed-agent project build --dry-run ``` ```bash -# Only after explicit user confirmation: -bl managed-agent project build --yes -``` - -```bash -# Only after explicit user confirmation: -bl managed-agent project build --project ./my-agent --yes +bl managed-agent project build --project ./my-agent ``` ### `bl managed-agent project init` @@ -1226,12 +1223,13 @@ bl managed-agent project build --project ./my-agent --yes #### Flags -| Flag | Type | Required | Description | -| ----------------------- | ------ | -------- | --------------------------------------------------- | -| `--project ` | string | no | Directory project root (default: current directory) | +| Flag | Type | Required | Description | +| ----------------------- | ------ | -------- | ----------------------------------------------------------------------------- | +| `--project ` | string | no | Directory project root (default: ./managed-agent under the current directory) | #### Notes +- Without --project, creates a managed-agent/ subdirectory. Enter it before running other project commands. Use --project . to initialize in place or convert the current agents.yaml; existing project files are not overwritten. - New projects include Skill, File, Vault, and Environment examples under each resource directory's \_examples/. They are not referenced by agent.json and are excluded from Build/Publish. Copy an example outside \_examples/ to enable it, then configure its Agent reference. #### Examples @@ -1244,6 +1242,10 @@ bl managed-agent project init bl managed-agent project init --project ./my-agent ``` +```bash +bl managed-agent project init --project . +``` + ### `bl managed-agent project publish` | Field | Value |