diff --git a/README.md b/README.md index affd705..d1d3c1e 100644 --- a/README.md +++ b/README.md @@ -77,10 +77,11 @@ The mechanics are a single `agents.yaml`, a `validate → plan → apply` workfl ## Quick start ```bash -agents project init # create a directory project (or convert agents.yaml) +agents project init # create a managed-agent/ subdirectory +cd managed-agent agents project validate # validate JSON, Markdown, skills, and local files agents project build --dry-run # preview organization and generated YAML -agents project build -y # freeze the current source into a Build +agents project build # freeze the current source into a Build (no confirmation) agents project publish -y # publish exactly that Build and record a version agents project workbench # edit and debug the same directory project ``` @@ -89,6 +90,8 @@ Directory projects keep global settings in `project.json`, each Agent under `age Fresh Init includes Skill, File, Vault, and Environment examples under each resource directory's `_examples/`, with bilingual configuration instructions. They are not linked in `agent.json`, do not enter generated YAML, and are not published remotely. Copy an example outside `_examples/` and configure its Agent reference to enable it. +Init defaults to `./managed-agent`. Use `agents project init --project .` to initialize in place or convert an existing `agents.yaml`; other project commands still default to the current working directory. + Workbench and CLI share `agents project version status|enable|disable|list|preview|restore`. Versions are Git-independent full source-tree snapshots: immutable manifests point to content-addressed text and binary blobs, while `.openagentpack/state.json` is always excluded. Restore writes a historical tree forward into the working directory without moving version history or remote State. Deployment and Channel declarations remain read-only in Workbench but participate in full project Publish. The original YAML workflow remains available through `agents init`, `validate`, `plan`, `apply`, and `destroy`. `agents playground -f agents.yaml` continues to open a YAML Agent Session Preview, but YAML Apply no longer creates project versions and cannot be used inside a directory-project root. diff --git a/apps/server/tests/project-declarations.test.ts b/apps/server/tests/project-declarations.test.ts index 1dfdf08..e536fa2 100644 --- a/apps/server/tests/project-declarations.test.ts +++ b/apps/server/tests/project-declarations.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { chmod, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -217,18 +217,36 @@ describe("directory project declaration editing", () => { expect(await readFile(statePath, "utf8")).toBe('{"remote":"latest"}\n'); }); - test("removes a File declaration without deleting its local source", async () => { + test("protects an automatically associated Agent-local File even without an authored mount", async () => { const { directory, manager } = await projectFixture({ fileMount: false }); const listed = await listProjectDeclarations(manager); + expect(resource(listed.resources, "file", "input").references.map((reference) => reference.path)).toEqual([ + "agents.assistant.files", + ]); + const preview = await previewDeclarationChange( + { type: "file", id: "input", baseRevision: listed.revision, action: "delete" }, + manager, + ); + expect(preview.can_commit).toBe(false); + expect((await stat(join(directory, "agents/assistant/files/input/file.json"))).isFile()).toBe(true); + }); + + test("removes an unreferenced shared File declaration without deleting its local source", async () => { + const { directory, manager } = await projectFixture({ fileMount: false }); + await mkdir(join(directory, "resources/files"), { recursive: true }); + const fileDirectory = join(directory, "resources/files/input"); + await rename(join(directory, "agents/assistant/files/input"), fileDirectory); + await manager.refreshAfterSourceMutation(); + const listed = await listProjectDeclarations(manager); await commitDeclarationChange( { type: "file", id: "input", baseRevision: listed.revision, action: "delete" }, manager, ); - expect(await stat(join(directory, "agents/assistant/files/input/file.json")).catch(() => null)).toBeNull(); - expect(await readFile(join(directory, "agents/assistant/files/input/input.txt"), "utf8")).toBe("Keep local file\n"); + expect(await stat(join(fileDirectory, "file.json")).catch(() => null)).toBeNull(); + expect(await readFile(join(fileDirectory, "input.txt"), "utf8")).toBe("Keep local file\n"); expect( - await stat(join(directory, "agents/assistant/files/input", FILE_AUTO_ASSOCIATION_IGNORE_FILE)).then( + await stat(join(fileDirectory, FILE_AUTO_ASSOCIATION_IGNORE_FILE)).then( () => true, () => false, ), diff --git a/apps/server/tests/project-manager.test.ts b/apps/server/tests/project-manager.test.ts index 02edd5a..ee35b7e 100644 --- a/apps/server/tests/project-manager.test.ts +++ b/apps/server/tests/project-manager.test.ts @@ -29,6 +29,29 @@ describe("ProjectRuntimeManager", () => { const declarations = await listProjectDeclarations(manager); expect(declarations.resources.map((resource) => `${resource.type}.${resource.id}`)).toEqual(["agent.assistant"]); }); + test("uses Build-inferred Environment and Vault bindings in the Workbench runtime", async () => { + const directory = await initializedProject("automatic-bindings"); + for (const [resourcePath, declaration] of [ + ["environments/dev/environment.json", { id: "dev", config: { type: "cloud" } }], + ["vaults/secrets/vault.json", { id: "secrets", display_name: "Secrets", credentials: [] }], + ] as const) { + const path = join(directory, "agents/assistant", resourcePath); + await mkdir(join(path, ".."), { recursive: true }); + await writeFile(path, JSON.stringify(declaration)); + } + const manager = trackManager(new ProjectRuntimeManager(directory)); + await manager.ensureStarted(); + const snapshot = manager.getSnapshot(); + expect(snapshot.status).toBe("valid"); + expect(snapshot.config?.agents.assistant).toMatchObject({ environment: "dev", vault: "secrets" }); + const agentPath = join(directory, "agents/assistant/agent.json"); + expect(JSON.parse(await readFile(agentPath, "utf8"))).not.toHaveProperty("environment"); + await commitProjectBuild({ projectRoot: directory, baseRevision: snapshot.revision! }); + await manager.refreshAfterSourceMutation(); + expect(JSON.parse(await readFile(agentPath, "utf8"))).toMatchObject({ environment: "dev", vault: "secrets" }); + expect(manager.getSnapshot().config?.agents.assistant).toMatchObject({ environment: "dev", vault: "secrets" }); + }); + test("reloads generated Vault references using the project-local .env after Build", async () => { const directory = await initializedProject("vault-build"); const vaultPath = join(directory, "agents/assistant/vaults/secrets/vault.json"); diff --git a/apps/webui/src/i18n/index.ts b/apps/webui/src/i18n/index.ts index 6109472..7b1afbf 100644 --- a/apps/webui/src/i18n/index.ts +++ b/apps/webui/src/i18n/index.ts @@ -6,7 +6,7 @@ export const LANGUAGE_STORAGE_KEY = "openagentpack.workbench.language"; function detectedLanguage(): SupportedLanguage { if (typeof window === "undefined") return "en-US"; - return normalizeLanguage(window.localStorage.getItem(LANGUAGE_STORAGE_KEY) ?? window.navigator.language); + return normalizeLanguage(window.localStorage.getItem(LANGUAGE_STORAGE_KEY) ?? "en-US"); } void i18n.use(initReactI18next).init({ diff --git a/apps/webui/tests/i18n.test.ts b/apps/webui/tests/i18n.test.ts index cd5ee1f..4981218 100644 --- a/apps/webui/tests/i18n.test.ts +++ b/apps/webui/tests/i18n.test.ts @@ -8,7 +8,70 @@ function leafKeys(value: object, prefix = ""): string[] { }); } +function loadWorkbenchLanguage(storedLanguage: string | null, nextLanguage?: string) { + const entryUrl = new URL("../src/i18n/index.ts", import.meta.url).href; + // Isolate browser globals and the i18next singleton from other tests. + const result = Bun.spawnSync({ + cmd: [ + process.execPath, + "--eval", + ` + const preferences = new Map([["openagentpack.workbench.language", ${JSON.stringify(storedLanguage)}]]); + globalThis.window = { + navigator: { language: "zh-CN" }, + localStorage: { + getItem: (key) => preferences.get(key) ?? null, + setItem: (key, value) => preferences.set(key, value), + }, + }; + globalThis.document = { documentElement: { lang: "" } }; + const { i18n, LANGUAGE_STORAGE_KEY } = await import(${JSON.stringify(entryUrl)}); + const initialLanguage = i18n.resolvedLanguage; + const nextLanguage = ${JSON.stringify(nextLanguage) ?? "undefined"}; + if (nextLanguage) await i18n.changeLanguage(nextLanguage); + console.log(JSON.stringify({ + initialLanguage, + language: i18n.resolvedLanguage, + storedLanguage: preferences.get(LANGUAGE_STORAGE_KEY), + documentLanguage: document.documentElement.lang, + })); + `, + ], + stdout: "pipe", + stderr: "pipe", + }); + expect(result.exitCode).toBe(0); + return JSON.parse(result.stdout.toString().trim().split("\n").at(-1) ?? ""); +} + describe("Workbench translations", () => { + test("defaults to English even when the browser language is Chinese", () => { + expect(loadWorkbenchLanguage(null)).toEqual({ + initialLanguage: "en-US", + language: "en-US", + storedLanguage: "en-US", + documentLanguage: "en-US", + }); + }); + + test("preserves an explicitly saved language preference", () => { + expect(loadWorkbenchLanguage("zh-CN").initialLanguage).toBe("zh-CN"); + expect(loadWorkbenchLanguage("en-US").initialLanguage).toBe("en-US"); + }); + + test("falls back to English for an unsupported saved language", () => { + expect(loadWorkbenchLanguage("fr-FR").initialLanguage).toBe("en-US"); + }); + + test("still switches languages and persists the user's selection", () => { + expect(loadWorkbenchLanguage(null, "zh-CN")).toEqual({ + initialLanguage: "en-US", + language: "zh-CN", + storedLanguage: "zh-CN", + documentLanguage: "zh-CN", + }); + }); + test("English and Chinese resources expose the same keys", () => { expect(leafKeys(zhCN).sort()).toEqual(leafKeys(enUS).sort()); }); diff --git a/bun.lock b/bun.lock index 8e94b15..0bce20f 100644 --- a/bun.lock +++ b/bun.lock @@ -67,7 +67,7 @@ }, "packages/cli": { "name": "@openagentpack/cli", - "version": "0.7.0", + "version": "0.7.1", "bin": { "agents": "dist/bin/agents.js", }, @@ -94,7 +94,7 @@ }, "packages/playground": { "name": "@openagentpack/playground", - "version": "0.7.0", + "version": "0.7.1", "bin": { "agents-playground": "dist/bin/playground.js", }, @@ -117,7 +117,7 @@ }, "packages/sdk": { "name": "@openagentpack/sdk", - "version": "0.7.0", + "version": "0.7.1", "dependencies": { "jszip": "^3.10.1", "yaml": "^2.9.0", diff --git a/docs/getting-started.md b/docs/getting-started.md index 5f481f6..bd30883 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -38,7 +38,9 @@ agents init The init wizard asks two questions — which provider(s) to use and what to name your first agent — then writes a starter `agents.yaml`. This is the compact YAML workflow used by `validate → plan → apply` and `agents playground`. -For a locally managed multi-file project and Workbench, start with `agents project init` instead. It creates `project.json`, `agents/assistant/agent.json`, and `instructions.md`, plus a Git-independent full-tree baseline. It also adds Skill/File/Vault/Environment samples and bilingual README files under each resource directory's `_examples/`. These samples are not referenced by the Agent and are excluded from Build/remote Publish; copy one outside `_examples/` and configure its Agent reference to enable it. Directory projects always use Bailian, so `project.json` does not contain Provider configuration. Environment, Vault, Memory Store, File, and Skill declarations belong in the Agent directory (or root shared-resource directories), not in `project.json`. For Agent-local content, Build automatically generates Skill metadata and its Agent reference from a directory containing `SKILL.md`. A File can be copied directly into the Agent's `files/` directory or placed first in a resource-ID directory containing one content file; Build generates its metadata and `/mnt/` Agent mount in either case. Explicit JSON always wins. Use `agents project validate`, `project build`, `project publish`, `project workbench`, and `project version ...`. The two workflows are intentionally separate: YAML Apply does not create directory versions, while project Publish consumes only `.openagentpack/build/agents.yaml` and never builds implicitly. +For a locally managed multi-file project and Workbench, start with `agents project init` instead. It creates `project.json`, `agents/assistant/agent.json`, and `instructions.md`, plus a Git-independent full-tree baseline. It also adds Skill/File/Vault/Environment samples and bilingual README files under each resource directory's `_examples/`. These samples are not referenced by the Agent and are excluded from Build/remote Publish; copy one outside `_examples/` into the owning Agent's resource directory, then Build adds its reference. Directory projects always use Bailian, so `project.json` does not contain Provider configuration. Environment, Vault, Memory Store, File, and Skill declarations belong in the Agent directory (or root shared-resource directories), not in `project.json`. Build links all active Agent-local resources, including those with existing metadata JSON. List references are appended without duplicates; Environment and Vault select the sole local candidate only when no explicit binding exists. Multiple candidates require an explicit selection. Existing references, Skill versions, and File mount paths are preserved; shared root resources still require explicit references. Provider capability validation remains enforced, so Bailian Memory Stores are still rejected. Build can also generate missing Skill metadata from `SKILL.md`, or File metadata from a file copied directly into `files/` or a resource-ID directory containing one content file. New File mounts default to `/mnt/`. Use `agents project validate`, `project build`, `project publish`, `project workbench`, and `project version ...`. The two workflows are intentionally separate: YAML Apply does not create directory versions, while project Publish consumes only `.openagentpack/build/agents.yaml` and never builds implicitly. + +Directory Init defaults to a new `managed-agent/` subdirectory in the current working directory. Run `cd managed-agent` before subsequent project commands, or pass `--project ./managed-agent`. To initialize in place or convert the current `agents.yaml`, explicitly use `agents project init --project .`. Build writes local files without confirmation; use `--dry-run` for a read-only preview. Publish still requires confirmation before remote changes. The generated file for the `bailian` provider and an agent named `assistant` looks like this: diff --git a/docs/getting-started.zh-CN.md b/docs/getting-started.zh-CN.md index 9cb5f59..fb7b91e 100644 --- a/docs/getting-started.zh-CN.md +++ b/docs/getting-started.zh-CN.md @@ -38,7 +38,9 @@ agents init init 向导问两个问题 —— 选哪个/哪些 Provider、给第一个 agent 起什么名 —— 然后生成 `agents.yaml`。这是供 `validate → plan → apply` 与 `agents playground` 使用的紧凑 YAML 流程。 -如需本地多文件项目和 Workbench,请改用 `agents project init`。它会创建 `project.json`、`agents/assistant/agent.json`、`instructions.md`,并建立不依赖 Git 的全目录基线版本。四类资源目录的 `_examples/` 下会生成完整配置示例和中英文 README;示例不写入 Agent 引用、不进入 Build 或远端 Publish,需要使用时复制到 `_examples/` 外并配置 Agent 引用。目录项目固定使用百炼,因此 `project.json` 不再包含 Provider 配置。Environment、Vault、Memory Store、File 和 Skill 声明放在 Agent 目录(或根共享资源目录),不再写入 `project.json`。对于 Agent 本地内容,Build 会为包含 `SKILL.md` 的目录自动生成 Skill 元数据并写入 Agent 引用;复制到 Agent `files/` 目录的文件可以直接放置,也可以先放入以资源 ID 命名且只含一个内容文件的子目录,Build 都会自动生成 File 元数据和 `/mnt/<文件名>` 挂载。显式 JSON 始终优先。后续使用 `agents project validate`、`project build`、`project publish`、`project workbench` 与 `project version ...`。两套流程明确隔离:传统 YAML Apply 不产生目录版本;project Publish 只使用 `.openagentpack/build/agents.yaml`,且不会隐式执行 Build。 +如需本地多文件项目和 Workbench,请改用 `agents project init`。它会创建 `project.json`、`agents/assistant/agent.json`、`instructions.md`,并建立不依赖 Git 的全目录基线版本。四类资源目录的 `_examples/` 下会生成完整配置示例和中英文 README;示例不写入 Agent 引用、不进入 Build 或远端 Publish,需要使用时复制到所属 Agent 资源目录的 `_examples/` 外,再由 Build 自动关联。目录项目固定使用百炼,因此 `project.json` 不再包含 Provider 配置。Environment、Vault、Memory Store、File 和 Skill 声明放在 Agent 目录(或根共享资源目录),不再写入 `project.json`。Build 会关联所有已启用的 Agent 本地资源,包括已有元数据 JSON 的资源:列表引用只追加缺失项;未指定 Environment、Vault 时自动关联唯一候选,多个候选则要求显式选择。已有引用、Skill 版本和 File 挂载路径均保留;根目录共享资源仍需显式引用。Provider 能力校验不变,百炼目前仍不支持 Memory Store。Build 也会为只有 `SKILL.md` 的目录生成 Skill 元数据,为直接放入 `files/` 或资源 ID 子目录中唯一的内容文件生成 File 元数据,新增挂载默认使用 `/mnt/<源文件名>`。后续使用 `agents project validate`、`project build`、`project publish`、`project workbench` 与 `project version ...`。两套流程明确隔离:传统 YAML Apply 不产生目录版本;project Publish 只使用 `.openagentpack/build/agents.yaml`,且不会隐式执行 Build。 + +目录 Init 默认在当前工作目录下创建 `managed-agent/` 子目录。后续项目操作请先执行 `cd managed-agent`,或传入 `--project ./managed-agent`。如需在当前目录初始化或转换已有的 `agents.yaml`,请显式执行 `agents project init --project .`。Build 无需确认即可写入本地文件;使用 `--dry-run` 可只读预览。Publish 变更远端资源前仍需确认。 为 `bailian` provider、agent 名为 `assistant` 生成的文件如下: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 425798a..fb19b71 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -52,9 +52,9 @@ Manage a Bailian directory project. `project.json` contains project-wide metadat | `project workbench` | Open the directory project Workbench. | | `project version ...` | Inspect, enable/disable, preview, or restore Git-independent full-tree versions. | -All project subcommands accept `--project ` (default current directory). `project build --dry-run` shows version-backed directory source changes and proposed organization moves; writing requires `--yes` or interactive confirmation. `project publish` requires a current Build and explicit confirmation, includes Deployment and Channel actions, and uses `.openagentpack/state.json` as the remote-resource ledger. +All project subcommands accept `--project `. Init defaults to a new `./managed-agent` subdirectory; use `project init --project .` to initialize or convert an existing YAML project in place. Other project commands default to the current directory, so enter `managed-agent/` after Init or pass its path explicitly. `project build` writes local source organization and Build output without confirmation; `--dry-run` only previews these changes. `project publish` requires a current Build and explicit confirmation (interactive or `--yes`), includes Deployment and Channel actions, and uses `.openagentpack/state.json` as the remote-resource ledger. -Fresh initialization leaves Agent resource references unset. Each generated resource example has a bilingual README explaining how to configure and enable it. Build skips `_examples/` directories during resource discovery, so these examples do not enter YAML, Workbench declarations, or remote Publish actions. Copy a resource outside `_examples/` and add its Agent reference when needed. Example files remain part of local source-version snapshots; do not put real secrets in them. +Fresh initialization leaves Agent resource references unset. Each generated resource example has a bilingual README explaining how to configure and enable it. Build skips `_examples/` directories during resource discovery, so these examples do not enter YAML, Workbench declarations, or remote Publish actions. Copy a resource outside `_examples/` into the owning Agent's resource directory, then Build adds its Agent reference. This also works for existing metadata JSON: Skills, Files, and Memory Stores append missing references; Environment and Vault auto-select a single local candidate only when no explicit binding exists. Multiple Environment/Vault candidates require an explicit selection and otherwise block Build. Existing references, Skill versions, and File mount paths are preserved. Shared root resources still require explicit references. Preview/dry-run do not write these changes. Provider capability checks remain enforced (Bailian does not currently support Memory Stores). Example files remain part of local source-version snapshots; do not put real secrets in them. Build externalizes literal `secret_value` and `access_token` fields in Agent-local/shared `vault.json` into the selected project's root `.env`, and writes `${AGENTS_VAULT_...}` references back to JSON. Existing references and `.env` entries are preserved; conflicting names receive suffixes. Preview/dry-run are read-only and hide secret values. `.env` has owner-only permissions (`0600`) and is excluded from local versions; it is not encrypted or automatically Git-ignored. Publish and Workbench read this project-root `.env` as a fallback to inherited environment variables, independently of the caller's current directory. diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 0366020..b5b69b9 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,13 @@ # @openagentpack/cli +## 0.7.1 + +### Patch Changes + +- Initialize directory projects in a managed-agent subdirectory by default. Allow local Build without confirmation while retaining Publish confirmation, and keep version-backed Build previews compatible with the updated command options. +- Updated dependencies + - @openagentpack/sdk@0.7.1 + ## 0.7.0 ### Minor Changes diff --git a/packages/cli/README.md b/packages/cli/README.md index 994ca3b..7e34cd2 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -14,9 +14,10 @@ The package installs the `agents` command. ```sh agents project init +cd managed-agent agents project validate agents project build --dry-run -agents project build --yes +agents project build agents project publish --yes ``` @@ -26,6 +27,8 @@ The legacy `agents init|validate|plan|apply` YAML workflow and `agents playgroun Use `agents --help` for command-specific options. +Init defaults to a new `managed-agent/` subdirectory under the current working directory. Use `agents project init --project .` to initialize in place or convert an existing `agents.yaml`. Other project commands continue to default to the current working directory. + ## Documentation - [Project README](https://github.com/modelstudioai/OpenAgentPack#readme) diff --git a/packages/cli/package.json b/packages/cli/package.json index 073264c..0a7777b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openagentpack/cli", - "version": "0.7.0", + "version": "0.7.1", "description": "Open Agent Pack — Declaratively manage AI agent infrastructure", "license": "Apache-2.0", "keywords": [ diff --git a/packages/cli/src/commands/project.ts b/packages/cli/src/commands/project.ts index e9d2e29..5aa5467 100644 --- a/packages/cli/src/commands/project.ts +++ b/packages/cli/src/commands/project.ts @@ -28,7 +28,7 @@ function projectRoot(options: ProjectOptions): string { } export async function projectInitCommand(options: ProjectOptions): Promise { - const result = await initializeDirectoryProject({ projectRoot: projectRoot(options) }); + const result = await initializeDirectoryProject({ projectRoot: options.project ?? "./managed-agent" }); if (options.json) return writeJson(result); log.success( result.converted_from_yaml ? "Converted agents.yaml into a directory project." : "Created directory project.", @@ -48,9 +48,7 @@ export async function projectValidateCommand(options: ProjectOptions): Promise { +export async function projectBuildCommand(options: ProjectOptions & { dryRun?: boolean }): Promise { const preview = await previewProjectBuild(projectRoot(options)); if (options.json) { if (options.dryRun) return writeJson(preview); @@ -70,16 +68,6 @@ export async function projectBuildCommand( } } if (options.dryRun) return; - if (!options.yes) { - const confirmed = await prompts.confirm({ - message: "Write the Build, inferred resource associations, and Vault secret references (.env)?", - output: process.stderr, - }); - if (prompts.isCancel(confirmed) || !confirmed) { - prompts.cancel("Build cancelled. Project files were not changed.", { output: process.stderr }); - return; - } - } const result = await commitProjectBuild({ projectRoot: preview.project_root, baseRevision: preview.project_revision, diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index 370cb78..28ef887 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -145,7 +145,11 @@ const projectCmd = program.command("project").description("Manage a directory-ba projectCmd .command("init") .description("Create or convert a directory-based Agent project") - .option("--project ", "Project directory", ".") + .option( + "--project ", + "Project directory (use . to initialize or convert agents.yaml in place)", + "./managed-agent", + ) .option("--json", "Output as JSON") .action(projectInitCommand); @@ -161,7 +165,6 @@ projectCmd .description("Organize project files and generate .openagentpack/build/agents.yaml") .option("--project ", "Project directory", ".") .option("--dry-run", "Preview Build without changing files") - .option("-y, --yes", "Skip confirmation prompt") .option("--json", "Output as JSON") .action(projectBuildCommand); diff --git a/packages/cli/tests/unit/cli-contracts.test.ts b/packages/cli/tests/unit/cli-contracts.test.ts index 595ec56..a2be733 100644 --- a/packages/cli/tests/unit/cli-contracts.test.ts +++ b/packages/cli/tests/unit/cli-contracts.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, test } from "bun:test"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, realpath, rm, stat, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { playgroundBrowserTargetFromSummary } from "../../src/commands/playground"; @@ -137,6 +137,60 @@ async function runAgents(args: string[], env: Record = {}, cwd = return { stdout, stderr, exitCode }; } +test("project init defaults to an isolated child directory and preserves parent files", async () => { + const directory = await realpath(await makeTempDir()); + await writeFile(join(directory, "agents.yaml"), "invalid parent YAML"); + if (process.platform !== "win32") await symlink("missing.md", join(directory, "CLAUDE.md")); + const result = await runAgents(["project", "init", "--json"], {}, directory); + expect(result.exitCode).toBe(0); + const initialized = JSON.parse(result.stdout); + expect(initialized.project_root).toBe(join(directory, "managed-agent")); + 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("invalid parent YAML"); + const instructions = join(directory, "managed-agent/agents/assistant/instructions.md"); + await writeFile(instructions, "user changes"); + const repeated = await runAgents(["project", "init", "--json"], {}, directory); + expect(repeated.exitCode).not.toBe(0); + expect(await readFile(instructions, "utf8")).toBe("user changes"); + const validated = await runAgents(["project", "validate", "--json"], {}, join(directory, "managed-agent")); + expect(validated.exitCode).toBe(0); + const root = join(directory, "managed-agent"); + const nested = join(root, "agents/assistant/skills"); + const misplaced = await runAgents(["project", "build", "--dry-run", "--json"], {}, nested); + expect(misplaced.exitCode).not.toBe(0); + expect(misplaced.stderr).toContain("Not a project root:"); + expect(misplaced.stderr).not.toMatch(/\p{Script=Han}/u); + expect(misplaced.stderr).toContain(`cd '${root}'`); + expect(misplaced.stderr).toContain(`--project '${root}'`); + await expect(stat(join(nested, ".openagentpack"))).rejects.toMatchObject({ code: "ENOENT" }); + const preview = await runAgents(["project", "build", "--dry-run", "--json"], {}, root); + expect(preview.exitCode).toBe(0); + await expect(stat(join(root, ".openagentpack/build"))).rejects.toMatchObject({ code: "ENOENT" }); + const built = await runAgents(["project", "build", "--json"], {}, root); + expect(built.exitCode).toBe(0); + expect((await stat(join(root, ".openagentpack/build/agents.yaml"))).isFile()).toBe(true); +}); + +test("project build needs no confirmation while publish retains its confirmation option", async () => { + const build = await runAgents(["project", "build", "--help"]); + expect(build.stdout).not.toContain("--yes"); + expect(build.stdout).toContain("--dry-run"); + const publish = await runAgents(["project", "publish", "--help"]); + expect(publish.stdout).toContain("--yes"); +}); + +test("project init respects explicit directories including dot", async () => { + for (const target of ["custom-agent", "."]) { + const directory = await realpath(await makeTempDir()); + const result = await runAgents(["project", "init", "--project", target, "--json"], {}, directory); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout).project_root).toBe(join(directory, target)); + expect(await stat(join(directory, "managed-agent")).catch(() => null)).toBeNull(); + } +}); + test("root version output matches package version", async () => { const manifest = (await Bun.file(join(REPO_ROOT, "package.json")).json()) as { version: string }; const result = await runAgents(["--version"]); diff --git a/packages/cli/tests/unit/version.test.ts b/packages/cli/tests/unit/version.test.ts index f62c0f7..8863d06 100644 --- a/packages/cli/tests/unit/version.test.ts +++ b/packages/cli/tests/unit/version.test.ts @@ -68,7 +68,7 @@ describe("agents project version", () => { test("project build renders directory source changes against the current version head", async () => { const root = await initializedProject(); - const initialBuild = await runAgents(["project", "build", "--project", root, "--yes"]); + const initialBuild = await runAgents(["project", "build", "--project", root]); expect(initialBuild.exitCode).toBe(0); await writeFile(join(root, "agents/assistant/instructions.md"), "Changed while offline.\n"); diff --git a/packages/playground/CHANGELOG.md b/packages/playground/CHANGELOG.md index 16ca191..d7e2550 100644 --- a/packages/playground/CHANGELOG.md +++ b/packages/playground/CHANGELOG.md @@ -1,5 +1,13 @@ # @openagentpack/playground +## 0.7.1 + +### Patch Changes + +- Default Workbench to English while preserving saved language preferences. Share Build-inferred resource bindings with the project runtime and protect automatically associated Agent-local files from deletion. +- Updated dependencies + - @openagentpack/sdk@0.7.1 + ## 0.7.0 ### Minor Changes diff --git a/packages/playground/package.json b/packages/playground/package.json index 9ec7b62..ccdf121 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -1,6 +1,6 @@ { "name": "@openagentpack/playground", - "version": "0.7.0", + "version": "0.7.1", "description": "OpenAgentPack Playground — one-command local web UI for OpenAgentPack", "license": "Apache-2.0", "keywords": [ diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 64ca24d..4f4eb50 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -1,5 +1,11 @@ # @openagentpack/sdk +## 0.7.1 + +### Patch Changes + +- Automatically associate active Agent-local resources during Build while preserving explicit bindings, Skill versions, and File mount paths. Reject ambiguous Environment or Vault selections before writing, and report actionable project-root hints when commands run from a nested directory. + ## 0.7.0 ### Minor Changes diff --git a/packages/sdk/docs/project-workspace.md b/packages/sdk/docs/project-workspace.md index 1b2952e..43b4fb2 100644 --- a/packages/sdk/docs/project-workspace.md +++ b/packages/sdk/docs/project-workspace.md @@ -23,8 +23,8 @@ agents/assistant/ reserved `_examples/` child directory (both Agent-local and shared resources), so the examples never become generated YAML declarations or remote Publish actions, even when they contain normal `skill.json`, `file.json`, or `SKILL.md`. -To enable one, copy its resource directory outside `_examples/` and configure -its Agent reference using the included README. Vault placeholders are only +To enable one, copy its resource directory outside `_examples/` in the owning +Agent's resource directory, then run Build to add its Agent reference. Vault placeholders are only resolved after enabling. Examples remain local versioned source; never put real credentials in them. Init does not inject examples when converting an existing YAML project, overwrite an existing project, or run Build/Publish. @@ -45,8 +45,25 @@ agents// ``` Resources used outside their owning Agent are promoted during Build to -`resources///`. Agent-local File and Skill content supports -convention-based association during Build: +`resources///` (shared Skills use root `skills//`). +Build associates all active Agent-local resource declarations with their owning Agent, +including resources that already have metadata JSON: + +- Skills, Files, and Memory Stores append missing references to `agent.json.skills`, + `files`, and `memory_stores` without duplicates. Existing Skill versions and File + mount paths are retained; new File mounts default to `/mnt/`. +- Environment and Vault populate `agent.json.environment` and `vault` only when the + field is unset and there is exactly one local candidate. Multiple candidates + require an explicit selection; Build fails before writing rather than guessing. +- Explicit selections are never replaced, including invalid references (which still + fail validation). Shared root resources are not automatically assigned to every + Agent; they require explicit references. `_examples/` is always ignored. +- Preview/validate/dry-run calculate the same references without changing source. + Successful Build persists them to `agent.json`; repeated Build is idempotent. +- Provider capability validation still applies. Bailian currently rejects Memory + Store declarations; inferring a reference does not enable unsupported resources. + +Build can also infer missing Skill/File metadata from content: - A file copied directly to `agents//files/` is moved to `files//`, receives a generated `file.json`, and is added diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 22cae37..6700f55 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@openagentpack/sdk", - "version": "0.7.0", + "version": "0.7.1", "description": "OpenAgentPack SDK (Node-compatible runtime)", "license": "Apache-2.0", "keywords": [ diff --git a/packages/sdk/src/internal/project-workspace/index.ts b/packages/sdk/src/internal/project-workspace/index.ts index 724d334..dd62bb7 100644 --- a/packages/sdk/src/internal/project-workspace/index.ts +++ b/packages/sdk/src/internal/project-workspace/index.ts @@ -187,6 +187,23 @@ export async function resolveDirectoryProjectRoot(input = "."): Promise const root = resolve(input); const details = await stat(root).catch(() => null); if (!details?.isDirectory()) throw new UserError(`Project directory does not exist: ${root}`); + if (!(await pathExists(resolve(root, PROJECT_METADATA_FILE)))) { + let ancestor = dirname(root); + while (ancestor !== root) { + if (await pathExists(resolve(ancestor, PROJECT_METADATA_FILE))) { + const quotedRoot = `'${ancestor.replaceAll("'", "'\\''")}'`; + throw new UserError( + `Not a project root: ${root} (${PROJECT_METADATA_FILE} is missing).\n` + + `Project root: ${ancestor}\n` + + `Run from the project root: cd ${quotedRoot}\n` + + `Or specify the project directory: --project ${quotedRoot}`, + ); + } + const parent = dirname(ancestor); + if (parent === ancestor) break; + ancestor = parent; + } + } return root; } @@ -648,7 +665,7 @@ async function assembleProject( for (const skill of skills) { if (skillById.has(skill.id)) throw new UserError(`Duplicate local skill id: ${skill.id}`); skillById.set(skill.id, skill); - if (skill.ownerAgent && skill.inferred) { + if (skill.ownerAgent) { autoAssociateSkill(skill, projectRoot, agents, agentSources, autoAssociations); } } @@ -696,9 +713,7 @@ async function assembleProject( const key = `${resource.type}:${resource.id}`; if (resourceKeys.has(key)) throw new UserError(`Duplicate ${resource.type} id: ${resource.id}`); resourceKeys.add(key); - if (resource.type === "file" && resource.owner_agent && resource.inferred) { - autoAssociateFile(resource, projectRoot, agents, agentSources, autoAssociations); - } + autoAssociateDirectoryResource(resource, resources, projectRoot, agents, agentSources, autoAssociations); if ( resource.owner_agent && isDirectoryResourceShared(resource.type, resource.id, resource.owner_agent, agents, project) @@ -1034,6 +1049,54 @@ function autoAssociateSkill( }); } +function autoAssociateDirectoryResource( + resource: LocalDirectoryResource, + resources: LocalDirectoryResource[], + projectRoot: string, + agents: Record>, + agentSources: Map>, + autoAssociations: ProjectAutoAssociationPlan, +): void { + if (resource.type === "file") { + autoAssociateFile(resource, projectRoot, agents, agentSources, autoAssociations); + return; + } + const ownerAgent = resource.owner_agent; + if (!ownerAgent) return; + const agent = agents[ownerAgent]; + const agentSource = agentSources.get(ownerAgent); + if (!agent || !agentSource) return; + const agentPath = `agents/${ownerAgent}/agent.json`; + if (resource.type === "memory_store") { + if (agent.memory_stores !== undefined && !Array.isArray(agent.memory_stores)) return; + const references = Array.isArray(agent.memory_stores) ? agent.memory_stores : []; + if (references.includes(resource.id)) return; + agent.memory_stores = [...references, resource.id]; + agentSource.memory_stores = structuredClone(agent.memory_stores); + } else { + // Environment and Vault are single bindings. Never overwrite an explicit + // selection or silently choose the first of multiple local candidates. + if (agent[resource.type] !== undefined) return; + const candidates = resources.filter( + (candidate) => candidate.owner_agent === ownerAgent && candidate.type === resource.type, + ); + if (candidates.length > 1) { + throw new UserError( + `${agentPath}: multiple local ${resource.type} resources (${candidates.map((candidate) => candidate.id).join(", ")}). ` + + `Set '${resource.type}' explicitly to choose one.`, + ); + } + agent[resource.type] = resource.id; + agentSource[resource.type] = resource.id; + } + planJsonWrite(autoAssociations, projectRoot, resolve(projectRoot, agentPath), agentSource); + autoAssociations.warnings.push({ + severity: "warning", + code: `project.${resource.type}.agent_link.inferred`, + message: `${resource.type} '${resource.id}' will be added to ${agentPath}.`, + }); +} + function autoAssociateFile( resource: LocalDirectoryResource, projectRoot: string, diff --git a/packages/sdk/src/internal/project-workspace/scaffold.ts b/packages/sdk/src/internal/project-workspace/scaffold.ts index 2404674..a28aa30 100644 --- a/packages/sdk/src/internal/project-workspace/scaffold.ts +++ b/packages/sdk/src/internal/project-workspace/scaffold.ts @@ -38,11 +38,11 @@ Use this Skill when the user asks to summarize text. the directory is the upload source. Do not put secrets in these files. \`SKILL.md\` 是 Skill 指令,需要的脚本和素材也放在此目录;整个目录作为上传源,请勿放入密钥。 - This example is ignored by Build/Publish. Copy it from \`skills/_examples/example-skill/\` - to \`skills/example-skill/\`, then add \`"skills": ["example-skill"]\` to \`agent.json\`. - 此示例不参与构建/发布。复制到 \`skills/example-skill/\` 后,再在 Agent 中添加上述引用。 + to \`skills/example-skill/\`, then Build adds \`"example-skill"\` to \`agent.json.skills\` automatically. + 此示例不参与构建/发布。复制到 \`skills/example-skill/\` 后,Build 会自动补齐 Agent 引用。 - To add another Skill, copy this directory, change the directory name and \`skill.json.id\`, - then add its ID to \`agent.json.skills\`. A new directory with only \`SKILL.md\` is also discovered by Build. - 新增 Skill 时复制此目录、修改目录名和 ID,再在 Agent 中引用;只放 \`SKILL.md\` 的新目录也能由 Build 自动关联。 + then run Build to add its ID to \`agent.json.skills\`. A new directory with only \`SKILL.md\` is also discovered by Build. + 新增 Skill 时复制此目录、修改目录名和 ID,再运行 Build 自动关联;只放 \`SKILL.md\` 的新目录也能由 Build 自动关联。 - Delete this ignored example directory if not needed. For an enabled Skill, also remove its Agent reference. 不需要时可直接删除本示例目录;若已经启用,还需删除 Agent 引用。 `, @@ -67,8 +67,8 @@ at \`/mnt/example.md\` in agent.json. - \`source\` is relative to this resource directory. Keep \`id\` equal to the directory name. \`source\` 相对此资源目录解析,\`id\` 应与目录名一致。 - Build/Publish ignore this example. Copy \`files/_examples/example-file/\` to \`files/example-file/\` - to enable its declaration; add \`"files": [{"file": "example-file", "mount_path": "/mnt/example.md"}]\` to \`agent.json\`. - 此示例不参与构建/发布。复制到 \`files/example-file/\` 后启用声明,再添加上述挂载引用;挂载路径必须在 \`/mnt/\` 下。 + to enable its declaration; Build adds \`{"file": "example-file", "mount_path": "/mnt/example.md"}\` to \`agent.json.files\`. + 此示例不参与构建/发布。复制到 \`files/example-file/\` 后,Build 自动添加上述挂载引用;已有自定义挂载路径会保留,挂载路径必须在 \`/mnt/\` 下。 - Uploading a File does not permanently attach it to the remote Agent. These declarations supply default mounts for new Sessions; existing Sessions are unchanged. 上传 File 不等于永久绑定到远端 Agent;这里声明的是新 Session 的默认挂载,不修改已有 Session。 @@ -94,9 +94,10 @@ at \`/mnt/example.md\` in agent.json. Build/Publish ignore this directory; no example secret is required to open Workbench. Copy \`vaults/_examples/example-vault/\` to \`vaults/example-vault/\` to enable the declaration, -then add \`"vault": "example-vault"\` to \`agent.json\` and supply the secret. +then supply the secret and run Build. If \`agent.json.vault\` is unset, Build selects the sole local Vault automatically. +With multiple local Vaults, set \`"vault": "example-vault"\` explicitly; Build never overwrites an existing selection. 此示例不参与构建/发布,不需要配置密钥即可打开 Workbench。 -启用时,复制到 \`vaults/example-vault/\`,在 Agent 中添加上述引用,并配置密钥。 +启用时,复制到 \`vaults/example-vault/\`,配置密钥后运行 Build。未指定 Vault 时自动关联唯一候选;多个候选需显式选择,已有引用不覆盖。 The example \`vault.json\` contains: 示例 \`vault.json\` 的完整配置如下: @@ -145,8 +146,9 @@ ${JSON.stringify( [`${agentRoot}/environments/${RESOURCE_EXAMPLES_DIRECTORY}/example-env/README.md`]: `# Environment example / Environment 配置示例 - This is an ignored managed cloud environment example. Copy \`environments/_examples/example-env/\` - to \`environments/example-env/\` to enable it, then add \`"environment": "example-env"\` to \`agent.json\`. - 这是不参与构建/发布的托管云环境示例。复制到 \`environments/example-env/\` 后启用,再在 Agent 中添加上述引用。 + to \`environments/example-env/\`, then run Build. An unset \`agent.json.environment\` is linked to the sole local Environment. + With multiple local Environments, set \`"environment": "example-env"\` explicitly; existing selections are preserved. + 这是不参与构建/发布的托管云环境示例。复制到 \`environments/example-env/\` 后运行 Build;未指定环境时自动关联唯一候选,多个候选需显式选择,已有引用不覆盖。 Keep \`id\` equal to the directory name. / ID 应与目录名一致。 - Optional \`config\` fields include \`networking\`, \`packages\`, and \`setup_script\`. \`config\` 可按需增加网络策略、依赖包和初始化脚本,例如: @@ -159,8 +161,8 @@ ${JSON.stringify({ type: "cloud", packages: { pip: ["requests"] }, setup_script: packages/scripts take effect on the remote platform, not on your computer during Init/Build. 上面的片段只替换 \`config\`。Init/Build 不会在本机安装依赖或执行脚本;远端是否支持以 Provider 为准。 - Init leaves \`agent.json\` unchanged and only creates ignored resource examples. - Moving a declaration outside \`_examples/\` enables it for Build/Publish, even without an Agent reference. - Init 不添加 Agent 资源引用。将声明移到 \`_examples/\` 外即会进入构建/发布范围,即使尚未配置 Agent 引用。 + Moving a declaration outside \`_examples/\` enables it for Build/Publish; Build also fills in the owning Agent's missing reference. + Init 不添加 Agent 资源引用。将声明移到 \`_examples/\` 外后,Build 会补齐所属 Agent 的引用并纳入构建/发布。 - Delete this ignored example directory if not needed. For an enabled Environment, also remove its Agent reference. 不需要时可直接删除本示例目录;若已经启用,还需删除 Agent 的 Environment 引用。 `, diff --git a/packages/sdk/tests/unit/project-scaffold.test.ts b/packages/sdk/tests/unit/project-scaffold.test.ts index ad6488d..597995c 100644 --- a/packages/sdk/tests/unit/project-scaffold.test.ts +++ b/packages/sdk/tests/unit/project-scaffold.test.ts @@ -68,7 +68,7 @@ describe("directory project resource examples", () => { await expect(stat(resolve(root, ".openagentpack/state.json"))).rejects.toMatchObject({ code: "ENOENT" }); }); - test("only activates examples after copying them out and declaring Agent references", async () => { + test("automatically links copied examples during Build without requiring manual Agent references", async () => { const root = await fixture(); await initializeDirectoryProject({ projectRoot: root }); for (const [directory, resourceId] of [ @@ -85,13 +85,12 @@ describe("directory project resource examples", () => { } const agentPath = resolve(root, "agents/assistant/agent.json"); const agent = JSON.parse(await readFile(agentPath, "utf8")); - Object.assign(agent, { + const references = { skills: ["example-skill"], files: [{ file: "example-file", mount_path: "/mnt/example.md" }], environment: "example-env", vault: "example-vault", - }); - await writeFile(agentPath, JSON.stringify(agent)); + }; const preview = await previewProjectBuild(root); expect(preview.can_build).toBe(true); const loaded = (await inspectDirectoryProject(root)).loaded!; @@ -101,6 +100,10 @@ describe("directory project resource examples", () => { expect(Object.keys(loaded.config.vaults ?? {})).toEqual(["example-vault"]); expect(preview.after_yaml).toContain("mount_path: /mnt/example.md"); expect(preview.after_yaml).not.toContain("_examples"); + expect(loaded.config.agents.assistant).toMatchObject(references); + expect(JSON.parse(await readFile(agentPath, "utf8"))).toEqual(agent); + await commitProjectBuild({ projectRoot: root, baseRevision: preview.project_revision }); + expect(JSON.parse(await readFile(agentPath, "utf8"))).toEqual({ ...agent, ...references }); }); test("does not discover or migrate draft examples including reserved-root metadata", async () => { diff --git a/packages/sdk/tests/unit/project-workspace.test.ts b/packages/sdk/tests/unit/project-workspace.test.ts index e142920..8aa18eb 100644 --- a/packages/sdk/tests/unit/project-workspace.test.ts +++ b/packages/sdk/tests/unit/project-workspace.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; +import { parse } from "yaml"; import { resolveProjectConfigFromObject } from "../../src/index.ts"; import { acquireDirectoryProjectMutation, @@ -12,6 +13,8 @@ import { initializeDirectoryProject, planProjectPublish, previewProjectBuild, + resolveDirectoryProjectRoot, + validateDirectoryProject, } from "../../src/project-workspace.ts"; const temporaryDirectories: string[] = []; @@ -27,6 +30,37 @@ async function temporaryProject(): Promise { } describe("directory project build", () => { + test("rejects project subdirectories with an actionable root hint before scanning or writing", async () => { + const root = await temporaryProject(); + await initializeDirectoryProject({ projectRoot: root }); + const nested = resolve(root, "agents/assistant/skills"); + const before = await previewProjectBuild(root); + for (const inspect of [previewProjectBuild, validateDirectoryProject]) { + await expect(inspect(nested)).rejects.toThrow(`Project root: ${root}`); + await expect(inspect(nested)).rejects.toThrow(`cd '${root}'`); + await expect(inspect(nested)).rejects.toThrow(`--project '${root}'`); + } + await expect(stat(resolve(nested, ".openagentpack"))).rejects.toMatchObject({ code: "ENOENT" }); + expect((await previewProjectBuild(root)).project_revision).toBe(before.project_revision); + if (process.platform !== "win32") { + await symlink("missing.md", resolve(nested, "CLAUDE.md")); + await expect(previewProjectBuild(nested)).rejects.toThrow("Not a project root:"); + } + }); + + test("uses the nearest project marker and quotes root paths safely in hints", async () => { + const parent = await temporaryProject(); + await writeFile(resolve(parent, "project.json"), "{}"); + const root = resolve(parent, "owner's project"); + const nested = resolve(root, "agents/assistant/skills"); + await mkdir(nested, { recursive: true }); + await writeFile(resolve(root, "project.json"), "invalid JSON"); + await expect(resolveDirectoryProjectRoot(nested)).rejects.toThrow(`Project root: ${root}`); + await expect(resolveDirectoryProjectRoot(nested)).rejects.toThrow("owner'\\''s project'"); + // An existing root retains its own validation errors; do not redirect to its parent. + expect(await resolveDirectoryProjectRoot(root)).toBe(root); + }); + test("initializes a baseline and creates a revision-bound build", async () => { const root = await temporaryProject(); const initialized = await initializeDirectoryProject({ projectRoot: root }); @@ -49,6 +83,201 @@ describe("directory project build", () => { expect((await getProjectBuildStatus(root)).stale).toBe(true); }); + test("links all declared Agent-local resources without writing during Preview and is idempotent", async () => { + const root = await temporaryProject(); + await initializeDirectoryProject({ projectRoot: root }); + await writeAgentLocalResources(root); + const agentPath = resolve(root, "agents/assistant/agent.json"); + await chmod(agentPath, 0o640); + const beforeAgent = await readFile(agentPath, "utf8"); + const versionPath = resolve(root, ".openagentpack/versions/project/store.json"); + const beforeVersions = await readFile(versionPath, "utf8"); + const metadataPath = resolve(root, "agents/assistant/files/input/file.json"); + const beforeMetadata = await readFile(metadataPath, "utf8"); + const expected = { + environment: "dev", + vault: "secrets", + skills: ["writer"], + files: [{ file: "input", mount_path: "/mnt/input.txt" }], + }; + + const preview = await previewProjectBuild(root); + expect(preview.diagnostics).toEqual([]); + expect(preview.can_build).toBe(true); + expect(parse(preview.canonical_yaml).agents.assistant).toMatchObject(expected); + expect(preview.warnings.map((diagnostic) => diagnostic.code)).toEqual( + expect.arrayContaining( + ["skill", "file", "environment", "vault"].map((type) => `project.${type}.agent_link.inferred`), + ), + ); + expect(await readFile(agentPath, "utf8")).toBe(beforeAgent); + await expect(stat(resolve(root, ".openagentpack/build/agents.yaml"))).rejects.toMatchObject({ code: "ENOENT" }); + + const built = await commitProjectBuild({ projectRoot: root, baseRevision: preview.project_revision }); + expect(JSON.parse(await readFile(agentPath, "utf8"))).toMatchObject(expected); + expect( + parse(await readFile(resolve(root, ".openagentpack/build/agents.yaml"), "utf8")).agents.assistant, + ).toMatchObject(expected); + expect((await stat(agentPath)).mode & 0o777).toBe(0o640); + expect(await readFile(metadataPath, "utf8")).toBe(beforeMetadata); + expect(await readFile(versionPath, "utf8")).toBe(beforeVersions); + await expect(stat(resolve(root, ".openagentpack/state.json"))).rejects.toMatchObject({ code: "ENOENT" }); + + const next = await previewProjectBuild(root); + expect(next.warnings.some((diagnostic) => diagnostic.code.endsWith("agent_link.inferred"))).toBe(false); + const repeated = await commitProjectBuild({ projectRoot: root, baseRevision: next.project_revision }); + expect(repeated.project_revision).toBe(built.project_revision); + expect(repeated.yaml_hash).toBe(built.yaml_hash); + }); + + test("preserves explicit single bindings and custom list entries while adding missing local references", async () => { + const root = await temporaryProject(); + await initializeDirectoryProject({ projectRoot: root }); + await writeAgentLocalResources(root); + await writeResource(root, "agents/assistant/skills/helper/skill.json", { id: "helper" }); + await writeFile(resolve(root, "agents/assistant/skills/helper/SKILL.md"), "# Helper\n"); + await writeResource(root, "agents/assistant/files/extra/file.json", { id: "extra", source: "./extra.txt" }); + await writeFile(resolve(root, "agents/assistant/files/extra/extra.txt"), "Extra\n"); + await writeResource(root, "agents/assistant/environments/alternate/environment.json", { + id: "alternate", + config: { type: "cloud" }, + }); + await writeResource(root, "agents/assistant/vaults/alternate/vault.json", { + id: "alternate", + display_name: "Alternate", + credentials: [], + }); + const agentPath = resolve(root, "agents/assistant/agent.json"); + const explicit = { + ...JSON.parse(await readFile(agentPath, "utf8")), + environment: "alternate", + vault: "alternate", + skills: [{ type: "custom", skill_id: "writer", version: "7" }], + files: [{ file: "input", mount_path: "/mnt/custom.txt" }], + }; + await writeFile(agentPath, `${JSON.stringify(explicit, null, 2)}\n`); + + const preview = await previewProjectBuild(root); + expect(preview.can_build).toBe(true); + await commitProjectBuild({ projectRoot: root, baseRevision: preview.project_revision }); + expect(JSON.parse(await readFile(agentPath, "utf8"))).toEqual({ + ...explicit, + skills: [...explicit.skills, "helper"], + files: [...explicit.files, { file: "extra", mount_path: "/mnt/extra.txt" }], + }); + }); + + test("infers Memory Store list references without bypassing Bailian capability validation", async () => { + const root = await temporaryProject(); + await initializeDirectoryProject({ projectRoot: root }); + for (const id of ["facts", "notes"]) { + await writeResource(root, `agents/assistant/memory-stores/${id}/memory-store.json`, { id, description: id }); + } + await writeResource(root, "resources/memory-stores/shared/memory-store.json", { + id: "shared", + description: "Shared", + }); + const agentPath = resolve(root, "agents/assistant/agent.json"); + const agent = JSON.parse(await readFile(agentPath, "utf8")); + await writeFile(agentPath, `${JSON.stringify({ ...agent, memory_stores: ["notes"] })}\n`); + const before = await readFile(agentPath, "utf8"); + const preview = await previewProjectBuild(root); + expect(parse(preview.canonical_yaml).agents.assistant.memory_stores).toEqual(["notes", "facts"]); + expect(preview.warnings.map((diagnostic) => diagnostic.code)).toContain("project.memory_store.agent_link.inferred"); + expect(preview.can_build).toBe(false); + expect(preview.diagnostics.map((diagnostic) => diagnostic.code)).toContain("bailian.memory_store.unsupported"); + await expect(commitProjectBuild({ projectRoot: root, baseRevision: preview.project_revision })).rejects.toThrow( + "Project contains errors", + ); + expect(await readFile(agentPath, "utf8")).toBe(before); + }); + + for (const type of ["environment", "vault"] as const) { + test(`rejects ambiguous ${type} bindings before writing source or Build output`, async () => { + const root = await temporaryProject(); + await initializeDirectoryProject({ projectRoot: root }); + for (const id of ["first", "second"]) { + await writeResource(root, `agents/assistant/${type}s/${id}/${type}.json`, { + id, + ...(type === "environment" ? { config: { type: "cloud" } } : { display_name: id, credentials: [] }), + }); + } + await mkdir(resolve(root, "agents/assistant/skills/writer"), { recursive: true }); + await writeFile(resolve(root, "agents/assistant/skills/writer/SKILL.md"), "# Writer\n"); + const agentPath = resolve(root, "agents/assistant/agent.json"); + const before = await readFile(agentPath, "utf8"); + const preview = await previewProjectBuild(root); + expect(preview.can_build).toBe(false); + expect(preview.diagnostics[0]?.message).toContain(`multiple local ${type} resources (first, second)`); + expect(preview.diagnostics[0]?.message).toContain(`Set '${type}' explicitly`); + expect(preview.diagnostics[0]?.message).not.toMatch(/\p{Script=Han}/u); + await expect(commitProjectBuild({ projectRoot: root, baseRevision: preview.project_revision })).rejects.toThrow( + "Project contains errors", + ); + expect(await readFile(agentPath, "utf8")).toBe(before); + await expect(stat(resolve(root, "agents/assistant/skills/writer/skill.json"))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(stat(resolve(root, ".openagentpack/build/agents.yaml"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + } + + test("excludes examples and shared resources from automatic ownership and keeps other Agents isolated", async () => { + const root = await temporaryProject(); + await initializeDirectoryProject({ projectRoot: root }); + const initial = parse((await previewProjectBuild(root)).canonical_yaml); + expect(initial.agents.assistant).not.toHaveProperty("environment"); + expect(initial.agents.assistant).not.toHaveProperty("vault"); + expect(initial.agents.assistant).not.toHaveProperty("skills"); + expect(initial.agents.assistant).not.toHaveProperty("files"); + await writeAgentLocalResources(root); + await writeResource(root, "agents/reviewer/agent.json", { model: "qwen-plus" }); + await writeFile(resolve(root, "agents/reviewer/instructions.md"), "Review.\n"); + await writeResource(root, "agents/reviewer/environments/review/environment.json", { + id: "review", + config: { type: "cloud" }, + }); + await writeResource(root, "resources/environments/shared/environment.json", { + id: "shared", + config: { type: "cloud" }, + }); + await writeResource(root, "resources/vaults/shared/vault.json", { + id: "shared", + display_name: "Shared", + credentials: [], + }); + await writeResource(root, "resources/files/shared/file.json", { id: "shared", source: "./shared.txt" }); + await writeFile(resolve(root, "resources/files/shared/shared.txt"), "Shared\n"); + await writeResource(root, "skills/shared/skill.json", { id: "shared" }); + await writeFile(resolve(root, "skills/shared/SKILL.md"), "# Shared\n"); + + const preview = await previewProjectBuild(root); + expect(preview.can_build).toBe(true); + expect(preview.organization_moves).toEqual([]); + await commitProjectBuild({ projectRoot: root, baseRevision: preview.project_revision }); + const assistant = JSON.parse(await readFile(resolve(root, "agents/assistant/agent.json"), "utf8")); + expect(JSON.stringify(assistant)).not.toContain("shared"); + expect(JSON.stringify(assistant)).not.toContain("example"); + expect(assistant.environment).toBe("dev"); + expect(JSON.parse(await readFile(resolve(root, "agents/reviewer/agent.json"), "utf8"))).toEqual({ + model: "qwen-plus", + environment: "review", + }); + }); + + test("does not replace an invalid explicit environment reference with a local candidate", async () => { + const root = await temporaryProject(); + await initializeDirectoryProject({ projectRoot: root }); + await writeAgentLocalResources(root); + const agentPath = resolve(root, "agents/assistant/agent.json"); + const agent = JSON.parse(await readFile(agentPath, "utf8")); + await writeFile(agentPath, `${JSON.stringify({ ...agent, environment: "missing" })}\n`); + const preview = await previewProjectBuild(root); + expect(preview.can_build).toBe(false); + expect(preview.diagnostics.some((diagnostic) => diagnostic.message.includes("missing"))).toBe(true); + expect(JSON.parse(await readFile(agentPath, "utf8")).environment).toBe("missing"); + }); + test("auto-associates Agent-local Skill and File content during Build", async () => { const root = await temporaryProject(); await initializeDirectoryProject({ projectRoot: root }); @@ -628,6 +857,22 @@ async function writeResource(root: string, path: string, value: Record { + await writeResource(root, "agents/assistant/skills/writer/skill.json", { id: "writer" }); + await writeFile(resolve(root, "agents/assistant/skills/writer/SKILL.md"), "# Writer\n"); + await writeResource(root, "agents/assistant/files/input/file.json", { id: "input", source: "./input.txt" }); + await writeFile(resolve(root, "agents/assistant/files/input/input.txt"), "Input\n"); + await writeResource(root, "agents/assistant/environments/dev/environment.json", { + id: "dev", + config: { type: "cloud" }, + }); + await writeResource(root, "agents/assistant/vaults/secrets/vault.json", { + id: "secrets", + display_name: "Secrets", + credentials: [], + }); +} + async function resolvedPublishConfig(projectRoot: string, model: string) { return resolveProjectConfigFromObject( {