Skip to content

Commit aa14842

Browse files
committed
fix(managed-agent): improve project initialization and build validation
1 parent 2767491 commit aa14842

6 files changed

Lines changed: 323 additions & 33 deletions

File tree

docs/agents/command-flag-change.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
- 类型由 `ParsedFlags<typeof FLAGS>` 推导;避免手写 `flags.x as number` 这类断言
2020
- 单 flag 必填用 `required: true`;跨 flag / 值相关校验放 `validate`
2121
- 默认值 fallback 写在命令实现或 `Settings` 解析层,不要重复解析 env/config
22+
- 需要在高风险确认前检查本地路径时,可用异步 `validate`;runtime 会在鉴权和确认前等待它完成。这里只允许本地只读检查,不写文件、不请求远端。非缺参的环境错误应抛出 `BailianError`,避免裸命令调用被当成缺参而仅显示 help。
2223

2324
### B. 鉴权 / 全局选项
2425

packages/commands/src/commands/managed-agent/project.ts

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { stat } from "node:fs/promises";
2+
import { join } from "node:path";
13
import {
24
BailianError,
35
defineCommand,
@@ -14,6 +16,7 @@ import {
1416
planProjectPublish,
1517
previewProjectBuild,
1618
type ProjectBuildResolver,
19+
resolveDirectoryProjectRoot,
1720
validateDirectoryProject,
1821
} from "@openagentpack/sdk/project-workspace";
1922
import { CREDENTIALS_NOTE, resolveAgentProjectConfig } from "./_engine/config-loader.ts";
@@ -44,9 +47,23 @@ export const managedAgentProjectInit = defineCommand({
4447
},
4548
auth: "none",
4649
usageArgs: "[--project <directory>]",
47-
flags: PROJECT_FLAG,
48-
exampleArgs: ["", "--project ./my-agent"],
50+
flags: {
51+
project: {
52+
...PROJECT_FLAG.project,
53+
description: {
54+
"en-US": "Directory project root (default: ./managed-agent under the current directory)",
55+
"zh-CN": "目录项目根路径(默认:当前目录下的 ./managed-agent)",
56+
},
57+
},
58+
},
59+
exampleArgs: ["", "--project ./my-agent", "--project ."],
4960
notes: [
61+
{
62+
"en-US":
63+
"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.",
64+
"zh-CN":
65+
"不传 --project 时创建 managed-agent/ 子目录;后续项目操作请先进入该目录。使用 --project . 可在当前目录初始化或转换 agents.yaml;不会覆盖已有项目文件。",
66+
},
5067
{
5168
"en-US":
5269
"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({
5572
},
5673
],
5774
async run(ctx) {
75+
const projectRoot = ctx.flags.project ?? "./managed-agent";
5876
if (ctx.settings.dryRun) {
5977
emitResult(
6078
{
61-
would_initialize_project: ctx.flags.project ?? ".",
79+
would_initialize_project: projectRoot,
6280
},
6381
detectOutputFormat(ctx.settings.output),
6482
);
6583
return;
6684
}
67-
const result = await initializeDirectoryProject({ projectRoot: ctx.flags.project ?? "." });
85+
const result = await initializeDirectoryProject({ projectRoot });
6886
emitResult(result, detectOutputFormat(ctx.settings.output));
6987
},
7088
});
@@ -99,18 +117,34 @@ export const managedAgentProjectBuild = defineCommand({
99117
"zh-CN": "整理目录源文件并生成不可变的发布 Build",
100118
},
101119
auth: "none",
102-
risk: {
103-
level: "high",
104-
message: {
120+
notes: [
121+
{
105122
"en-US":
106-
"This organizes project source, moves literal Vault secrets into the local .env, and writes the previewed immutable Build.",
123+
"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.",
107124
"zh-CN":
108-
"该操作会整理项目源文件,将 Vault 明文密钥移入本地 .env,并写入已预览的不可变 Build。",
125+
"Build 无需确认即可写入本地项目文件,包括推断的资源关联及将 Vault 明文密钥移入 .env。使用 --dry-run 可只预览不写入。Publish 变更远端资源前仍需显式确认。",
109126
},
110-
},
127+
],
111128
usageArgs: "[--project <directory>]",
112129
flags: PROJECT_FLAG,
113-
exampleArgs: ["--dry-run", "--yes", "--project ./my-agent --yes"],
130+
exampleArgs: ["", "--dry-run", "--project ./my-agent"],
131+
async validate(flags) {
132+
await withAgentErrors(async () => {
133+
const root = await resolveDirectoryProjectRoot(flags.project ?? ".");
134+
const metadata = await stat(join(root, "project.json")).catch((error: unknown) => {
135+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
136+
throw error;
137+
});
138+
if (!metadata?.isFile()) {
139+
throw new BailianError(
140+
`Not a project root: ${root} (project.json is missing).`,
141+
ExitCode.USAGE,
142+
"Run from the directory containing project.json, or pass --project <directory>.",
143+
);
144+
}
145+
});
146+
return undefined;
147+
},
114148
async run(ctx) {
115149
const root = ctx.flags.project ?? ".";
116150
const preview = await previewProjectBuild(root);
@@ -120,7 +154,11 @@ export const managedAgentProjectBuild = defineCommand({
120154
return;
121155
}
122156
if (!preview.can_build)
123-
throw new BailianError("Directory project is invalid and cannot be built.", ExitCode.GENERAL);
157+
throw new BailianError(
158+
preview.diagnostics.find((diagnostic) => diagnostic.severity === "error")?.message ??
159+
"Directory project is invalid and cannot be built.",
160+
ExitCode.GENERAL,
161+
);
124162
const built = await commitProjectBuild({
125163
projectRoot: root,
126164
baseRevision: preview.project_revision,
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import {
2+
cp,
3+
mkdir,
4+
mkdtemp,
5+
readFile,
6+
realpath,
7+
rm,
8+
stat,
9+
symlink,
10+
writeFile,
11+
} from "node:fs/promises";
12+
import { tmpdir } from "node:os";
13+
import { join } from "node:path";
14+
import { fileURLToPath } from "node:url";
15+
import { runNodeMain } from "e2e/runner";
16+
import { afterEach, describe, expect, test } from "vite-plus/test";
17+
import { parseStdoutJson, runCommandHelp } from "./helpers.ts";
18+
19+
const routes = {
20+
"managed-agent project init": "managedAgentProjectInit",
21+
"managed-agent project build": "managedAgentProjectBuild",
22+
"managed-agent project publish": "managedAgentProjectPublish",
23+
"managed-agent project validate": "managedAgentProjectValidate",
24+
} as const;
25+
const directories: string[] = [];
26+
27+
async function temporaryDirectory() {
28+
const directory = await realpath(await mkdtemp(join(tmpdir(), "bailian-project-init-")));
29+
directories.push(directory);
30+
return directory;
31+
}
32+
33+
afterEach(async () => {
34+
for (const directory of directories.splice(0)) {
35+
await rm(directory, { recursive: true, force: true });
36+
}
37+
});
38+
39+
function runInit(directory: string, args: string[] = []) {
40+
return runProject(directory, "init", args);
41+
}
42+
43+
async function runProject(directory: string, subcommand: string, args: string[] = [], json = true) {
44+
const configRoot = await temporaryDirectory();
45+
return runNodeMain(
46+
fileURLToPath(new URL("./harness/main.ts", import.meta.url)),
47+
["managed-agent", "project", subcommand, ...args, ...(json ? ["--output", "json"] : [])],
48+
{
49+
cwd: directory,
50+
env: {
51+
BAILIAN_CONFIG_DIR: configRoot,
52+
BAILIAN_E2E_ROUTES: JSON.stringify(
53+
Object.entries(routes).map(([path, exportName]) => ({ path, export: exportName })),
54+
),
55+
},
56+
},
57+
);
58+
}
59+
60+
describe("e2e: managed-agent project init directory defaults", () => {
61+
test("build links copied resources and reports ambiguous environment bindings without writing", async () => {
62+
const directory = await temporaryDirectory();
63+
const initialized = await runInit(directory);
64+
expect(initialized.exitCode, initialized.stderr).toBe(0);
65+
const root = join(directory, "managed-agent");
66+
for (const [section, id] of [
67+
["skills", "example-skill"],
68+
["files", "example-file"],
69+
["environments", "example-env"],
70+
["vaults", "example-vault"],
71+
] as const) {
72+
await cp(
73+
join(root, "agents/assistant", section, "_examples", id),
74+
join(root, "agents/assistant", section, id),
75+
{ recursive: true },
76+
);
77+
}
78+
const agentPath = join(root, "agents/assistant/agent.json");
79+
const original = await readFile(agentPath, "utf8");
80+
const preview = await runProject(root, "build", ["--dry-run"]);
81+
expect(preview.exitCode, preview.stderr).toBe(0);
82+
expect(parseStdoutJson<{ can_build: boolean }>(preview.stdout).can_build).toBe(true);
83+
expect(await readFile(agentPath, "utf8")).toBe(original);
84+
const built = await runProject(root, "build");
85+
expect(built.exitCode, built.stderr).toBe(0);
86+
const agent = JSON.parse(await readFile(agentPath, "utf8"));
87+
expect(agent).toMatchObject({
88+
environment: "example-env",
89+
vault: "example-vault",
90+
skills: ["example-skill"],
91+
files: [{ file: "example-file", mount_path: "/mnt/example.md" }],
92+
});
93+
const alternatePath = join(root, "agents/assistant/environments/alternate");
94+
await mkdir(alternatePath);
95+
await writeFile(
96+
join(alternatePath, "environment.json"),
97+
JSON.stringify({ id: "alternate", config: { type: "cloud" } }),
98+
);
99+
delete agent.environment;
100+
await writeFile(agentPath, JSON.stringify(agent));
101+
const beforeConflict = await readFile(agentPath, "utf8");
102+
const buildPath = join(root, ".openagentpack/build/agents.yaml");
103+
const beforeBuild = await readFile(buildPath, "utf8");
104+
for (const json of [false, true]) {
105+
const conflict = await runProject(root, "build", [], json);
106+
expect(conflict.exitCode).toBe(1);
107+
expect(conflict.stderr).toContain("multiple local environment resources");
108+
expect(conflict.stderr).toContain("Set 'environment' explicitly");
109+
expect(conflict.stderr).not.toMatch(/\p{Script=Han}/u);
110+
}
111+
expect(await readFile(agentPath, "utf8")).toBe(beforeConflict);
112+
expect(await readFile(buildPath, "utf8")).toBe(beforeBuild);
113+
});
114+
115+
test("build checks directories and writes without confirmation while publish stays gated", async () => {
116+
const directory = await temporaryDirectory();
117+
const initialized = await runInit(directory);
118+
expect(initialized.exitCode, initialized.stderr).toBe(0);
119+
const root = join(directory, "managed-agent");
120+
const nested = join(root, "agents/assistant/skills");
121+
for (const args of [[], ["--dry-run"]]) {
122+
const result = await runProject(nested, "build", args, false);
123+
expect(result.exitCode, result.stderr).toBe(2);
124+
expect(result.stderr).toContain("Not a project root:");
125+
expect(result.stderr).not.toMatch(/\p{Script=Han}/u);
126+
expect(result.stderr).toContain(`cd '${root}'`);
127+
expect(result.stderr).not.toContain("high-risk");
128+
expect(result.stderr).not.toContain("Usage:");
129+
}
130+
const explicit = await runProject(root, "build", ["--project", nested]);
131+
expect(explicit.exitCode).toBe(2);
132+
expect(explicit.stderr).toContain(`--project '${root}'`);
133+
const noMarker = await runProject(directory, "build", [], false);
134+
expect(noMarker.exitCode).toBe(2);
135+
expect(noMarker.stderr).toContain("project.json");
136+
expect(noMarker.stderr).not.toMatch(/\p{Script=Han}/u);
137+
expect(noMarker.stderr).not.toContain("high-risk");
138+
expect(await stat(join(nested, ".openagentpack")).catch(() => null)).toBeNull();
139+
140+
const preview = await runProject(root, "build", ["--dry-run"]);
141+
expect(preview.exitCode, preview.stderr).toBe(0);
142+
expect(await stat(join(root, ".openagentpack/build")).catch(() => null)).toBeNull();
143+
const built = await runProject(root, "build");
144+
expect(built.exitCode, built.stderr).toBe(0);
145+
expect(built.stderr).not.toContain("requires_confirmation");
146+
expect((await stat(join(root, ".openagentpack/build/agents.yaml"))).isFile()).toBe(true);
147+
const explicitValid = await runProject(nested, "build", ["--project", root]);
148+
expect(explicitValid.exitCode, explicitValid.stderr).toBe(0);
149+
const storePath = join(root, ".openagentpack/versions/project/store.json");
150+
const beforePublish = await readFile(storePath, "utf8");
151+
const publish = await runProject(root, "publish");
152+
expect(publish.exitCode).toBe(7);
153+
expect(publish.stderr).toContain("requires_confirmation");
154+
expect(await readFile(storePath, "utf8")).toBe(beforePublish);
155+
});
156+
157+
test("only publish help includes confirmation; build keeps dry-run", async () => {
158+
const build = await runCommandHelp(routes, ["managed-agent", "project", "build", "--help"]);
159+
expect(build.stderr).not.toContain("--yes");
160+
expect(build.stderr).toContain("--dry-run");
161+
const publish = await runCommandHelp(routes, ["managed-agent", "project", "publish", "--help"]);
162+
expect(publish.stderr).toContain("--yes");
163+
const directory = await temporaryDirectory();
164+
const removedFlag = await runProject(directory, "build", ["--yes"]);
165+
expect(removedFlag.exitCode).toBe(2);
166+
expect(removedFlag.stderr).toMatch(/Unknown flag.*--yes/);
167+
});
168+
169+
test("nested build and validate explain the project root without changing directories", async () => {
170+
const directory = await temporaryDirectory();
171+
const initialized = await runInit(directory);
172+
expect(initialized.exitCode, initialized.stderr).toBe(0);
173+
const root = join(directory, "managed-agent");
174+
const nested = join(root, "agents/assistant/skills");
175+
for (const command of ["build", "validate"]) {
176+
const result = await runProject(nested, command, ["--dry-run"]);
177+
expect(result.exitCode).not.toBe(0);
178+
expect(result.stderr).toContain("Not a project root:");
179+
expect(result.stderr).not.toMatch(/\p{Script=Han}/u);
180+
expect(result.stderr).toContain(`cd '${root}'`);
181+
expect(result.stderr).toContain(`--project '${root}'`);
182+
expect(result.stderr).not.toContain("ERR_MODULE_NOT_FOUND");
183+
}
184+
expect(await stat(join(nested, ".openagentpack")).catch(() => null)).toBeNull();
185+
const corrected = await runProject(nested, "build", ["--project", root, "--dry-run"]);
186+
expect(corrected.exitCode, corrected.stderr).toBe(0);
187+
});
188+
189+
test("help describes the subdirectory default and explicit in-place initialization", async () => {
190+
const result = await runCommandHelp(routes, ["managed-agent", "project", "init", "--help"]);
191+
expect(result.exitCode).toBe(0);
192+
expect(result.stderr).toContain("./managed-agent");
193+
expect(result.stderr).toContain("--project .");
194+
});
195+
196+
test("default dry-run reports the child directory without creating it", async () => {
197+
const directory = await temporaryDirectory();
198+
const result = await runInit(directory, ["--dry-run"]);
199+
expect(result.exitCode, result.stderr).toBe(0);
200+
expect(parseStdoutJson(result.stdout)).toEqual({ would_initialize_project: "./managed-agent" });
201+
expect(await stat(join(directory, "managed-agent")).catch(() => null)).toBeNull();
202+
});
203+
204+
test("initializes only the child, ignores parent source, and rejects repeated init without overwriting", async () => {
205+
const directory = await temporaryDirectory();
206+
const parentYaml = "not a valid project declaration";
207+
await writeFile(join(directory, "agents.yaml"), parentYaml);
208+
if (process.platform !== "win32") {
209+
await symlink("missing-instructions.md", join(directory, "CLAUDE.md"));
210+
}
211+
const result = await runInit(directory);
212+
expect(result.exitCode, result.stderr).toBe(0);
213+
const projectRoot = join(directory, "managed-agent");
214+
const initialized = parseStdoutJson<{
215+
project_root: string;
216+
baseline_version: string;
217+
converted_from_yaml: boolean;
218+
}>(result.stdout);
219+
expect(initialized.project_root).toBe(projectRoot);
220+
expect(initialized.baseline_version).toHaveLength(64);
221+
expect(initialized.converted_from_yaml).toBe(false);
222+
expect(await stat(join(directory, "project.json")).catch(() => null)).toBeNull();
223+
expect(await readFile(join(directory, "agents.yaml"), "utf8")).toBe(parentYaml);
224+
expect((await stat(join(projectRoot, "agents/assistant/agent.json"))).isFile()).toBe(true);
225+
expect(
226+
(await stat(join(projectRoot, ".openagentpack/versions/project/store.json"))).isFile(),
227+
).toBe(true);
228+
const instructions = join(projectRoot, "agents/assistant/instructions.md");
229+
await writeFile(instructions, "user changes");
230+
const repeated = await runInit(directory);
231+
expect(repeated.exitCode).not.toBe(0);
232+
expect(repeated.stderr).toContain("already exists");
233+
expect(await readFile(instructions, "utf8")).toBe("user changes");
234+
});
235+
236+
test("explicit paths, including dot, remain exact project roots", async () => {
237+
for (const target of ["custom-agent", "."]) {
238+
const directory = await temporaryDirectory();
239+
const result = await runInit(directory, ["--project", target]);
240+
expect(result.exitCode, result.stderr).toBe(0);
241+
expect(parseStdoutJson<{ project_root: string }>(result.stdout).project_root).toBe(
242+
join(directory, target),
243+
);
244+
expect(await stat(join(directory, "managed-agent")).catch(() => null)).toBeNull();
245+
}
246+
});
247+
});

packages/core/src/types/command.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,8 +287,10 @@ export interface Command<F extends FlagsDef = FlagsDef> {
287287
* Cross-flag validation, after parsing and before run. Return an error message
288288
* → UsageError; undefined to pass. Single-flag `required` is enforced by the
289289
* parser — use this for rules spanning flags or depending on a flag's *value*.
290+
* May be async for read-only local preflight. Runtime awaits it before auth
291+
* and confirmation. Do not perform remote requests or local writes here.
290292
*/
291-
validate?: (flags: ParsedFlags<F>) => string | undefined;
293+
validate?: (flags: ParsedFlags<F>) => string | undefined | Promise<string | undefined>;
292294
run: (ctx: CommandContext<F>) => Promise<void>;
293295
}
294296

packages/runtime/src/create-cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
217217
parsedFlags,
218218
Object.keys(res.command.flags ?? {}),
219219
) as ParsedFlags<FlagsDef>;
220-
const invalid = res.command.validate?.(ownFlags);
220+
const invalid = await res.command.validate?.(ownFlags);
221221
if (invalid) throw new UsageError(invalid);
222222

223223
// 校验通过 → 建源、解析 settings、组 ctx,进中间件执行命令。

0 commit comments

Comments
 (0)