diff --git a/src/cli/commands/agents/pull.ts b/src/cli/commands/agents/pull.ts index 282bec85..cde875db 100644 --- a/src/cli/commands/agents/pull.ts +++ b/src/cli/commands/agents/pull.ts @@ -24,18 +24,14 @@ async function pullAgentsAction(): Promise { } ); - if (remoteAgents.items.length === 0) { - return { outroMessage: "No agents found on Base44" }; - } - const { written, deleted } = await runTask( - "Writing agent files", + "Syncing agent files", async () => { return await writeAgents(agentsDir, remoteAgents.items); }, { - successMessage: "Agent files written successfully", - errorMessage: "Failed to write agent files", + successMessage: "Agent files synced successfully", + errorMessage: "Failed to sync agent files", } ); @@ -45,6 +41,9 @@ async function pullAgentsAction(): Promise { if (deleted.length > 0) { log.warn(`Deleted: ${deleted.join(", ")}`); } + if (written.length === 0 && deleted.length === 0) { + log.info("All agents are already up to date"); + } return { outroMessage: `Pulled ${remoteAgents.total} agents to ${agentsDir}`, diff --git a/src/core/resources/agent/config.ts b/src/core/resources/agent/config.ts index c29a5a3b..0d85b58d 100644 --- a/src/core/resources/agent/config.ts +++ b/src/core/resources/agent/config.ts @@ -1,4 +1,5 @@ import { join } from "node:path"; +import { isDeepStrictEqual } from "node:util"; import { globby } from "globby"; import { SchemaValidationError } from "@/core/errors.js"; import { @@ -33,9 +34,11 @@ export function generateAgentConfigContent(name: string): string { `; } -async function readAgentFile(agentPath: string): Promise { - const parsed = await readJsonFile(agentPath); - const result = AgentConfigSchema.safeParse(parsed); +async function readAgentFile( + agentPath: string +): Promise<{ data: AgentConfig; raw: unknown }> { + const raw = await readJsonFile(agentPath); + const result = AgentConfigSchema.safeParse(raw); if (!result.success) { throw new SchemaValidationError( @@ -45,10 +48,16 @@ async function readAgentFile(agentPath: string): Promise { ); } - return result.data; + return { data: result.data, raw }; } -export async function readAllAgents(agentsDir: string): Promise { +interface AgentFileEntry { + data: AgentConfig; + raw: unknown; + filePath: string; +} + +async function readAgentFiles(agentsDir: string): Promise { if (!(await pathExists(agentsDir))) { return []; } @@ -58,46 +67,66 @@ export async function readAllAgents(agentsDir: string): Promise { absolute: true, }); - const agents = await Promise.all( - files.map((filePath) => readAgentFile(filePath)) + return await Promise.all( + files.map(async (filePath) => { + const { data, raw } = await readAgentFile(filePath); + return { data, raw, filePath }; + }) ); +} + +export async function readAllAgents(agentsDir: string): Promise { + const entries = await readAgentFiles(agentsDir); const names = new Set(); - for (const agent of agents) { - if (names.has(agent.name)) { - throw new Error(`Duplicate agent name "${agent.name}"`); + for (const { data } of entries) { + if (names.has(data.name)) { + throw new Error(`Duplicate agent name "${data.name}"`); } - names.add(agent.name); + names.add(data.name); } - return agents; + return entries.map((e) => e.data); } export async function writeAgents( agentsDir: string, remoteAgents: AgentConfigApiResponse[] ): Promise<{ written: string[]; deleted: string[] }> { - const existingAgents = await readAllAgents(agentsDir); + const entries = await readAgentFiles(agentsDir); + + const nameToEntry = new Map(); + for (const entry of entries) { + if (nameToEntry.has(entry.data.name)) { + throw new Error(`Duplicate agent name "${entry.data.name}"`); + } + nameToEntry.set(entry.data.name, entry); + } + const newNames = new Set(remoteAgents.map((a) => a.name)); - const toDelete = existingAgents.filter((a) => !newNames.has(a.name)); - for (const agent of toDelete) { - const files = await globby(`${agent.name}.${CONFIG_FILE_EXTENSION_GLOB}`, { - cwd: agentsDir, - absolute: true, - }); - for (const filePath of files) { - await deleteFile(filePath); + const deleted: string[] = []; + for (const [name, entry] of nameToEntry) { + if (!newNames.has(name)) { + await deleteFile(entry.filePath); + deleted.push(name); } } + const written: string[] = []; for (const agent of remoteAgents) { - const filePath = join(agentsDir, `${agent.name}.${CONFIG_FILE_EXTENSION}`); + const existing = nameToEntry.get(agent.name); + + if (existing && isDeepStrictEqual(existing.raw, agent)) { + continue; + } + + const filePath = + existing?.filePath ?? + join(agentsDir, `${agent.name}.${CONFIG_FILE_EXTENSION}`); await writeJsonFile(filePath, agent); + written.push(agent.name); } - const written = remoteAgents.map((a) => a.name); - const deleted = toDelete.map((a) => a.name); - return { written, deleted }; } diff --git a/tests/cli/agents_pull.spec.ts b/tests/cli/agents_pull.spec.ts index f1d346f9..5823e585 100644 --- a/tests/cli/agents_pull.spec.ts +++ b/tests/cli/agents_pull.spec.ts @@ -11,7 +11,7 @@ describe("agents pull command", () => { const result = await t.run("agents", "pull"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("No agents found on Base44"); + t.expectResult(result).toContain("All agents are already up to date"); }); it("fails when not in a project directory", async () => { @@ -37,7 +37,7 @@ describe("agents pull command", () => { t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Agents fetched successfully"); - t.expectResult(result).toContain("Agent files written successfully"); + t.expectResult(result).toContain("Agent files synced successfully"); t.expectResult(result).toContain("Pulled 2 agents"); }); diff --git a/tests/core/agents_write.spec.ts b/tests/core/agents_write.spec.ts new file mode 100644 index 00000000..450fbb18 --- /dev/null +++ b/tests/core/agents_write.spec.ts @@ -0,0 +1,206 @@ +import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + readAllAgents, + writeAgents, +} from "../../src/core/resources/agent/config.js"; +import type { AgentConfigApiResponse } from "../../src/core/resources/agent/schema.js"; + +describe("writeAgents", () => { + it("writes remote agents to files", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "agents-test-")); + + try { + const remoteAgents: AgentConfigApiResponse[] = [ + { + name: "support", + description: "Help desk", + instructions: "Be helpful", + }, + { name: "sales", description: "Sales bot", instructions: "Sell stuff" }, + ]; + + const { written, deleted } = await writeAgents(tmpDir, remoteAgents); + + expect(written).toEqual(["support", "sales"]); + expect(deleted).toEqual([]); + + const agents = await readAllAgents(tmpDir); + expect(agents).toHaveLength(2); + expect(agents.map((a) => a.name).sort()).toEqual(["sales", "support"]); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("deletes local agents not in remote list", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "agents-test-")); + + try { + const initial: AgentConfigApiResponse[] = [ + { + name: "support", + description: "Help desk", + instructions: "Be helpful", + }, + { name: "sales", description: "Sales bot", instructions: "Sell stuff" }, + ]; + await writeAgents(tmpDir, initial); + + const remote: AgentConfigApiResponse[] = [ + { + name: "support", + description: "Help desk", + instructions: "Be helpful", + }, + ]; + const { written, deleted } = await writeAgents(tmpDir, remote); + + expect(written).toEqual([]); + expect(deleted).toEqual(["sales"]); + + const agents = await readAllAgents(tmpDir); + expect(agents).toHaveLength(1); + expect(agents[0].name).toBe("support"); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("writes to existing file when name matches even if filename differs", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "agents-test-")); + + try { + await writeFile( + join(tmpDir, "my-custom-agent.jsonc"), + JSON.stringify({ + name: "support", + description: "Help desk", + instructions: "Be helpful", + }) + ); + + const remoteAgents: AgentConfigApiResponse[] = [ + { + name: "support", + description: "Updated help desk", + instructions: "Be very helpful", + }, + ]; + + const { written, deleted } = await writeAgents(tmpDir, remoteAgents); + + expect(written).toEqual(["support"]); + expect(deleted).toEqual([]); + + const files = await readdir(tmpDir); + expect(files).toEqual(["my-custom-agent.jsonc"]); + + const content = JSON.parse( + await readFile(join(tmpDir, "my-custom-agent.jsonc"), "utf-8") + ); + expect(content.description).toBe("Updated help desk"); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("deletes file with non-matching filename when name is not in remote", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "agents-test-")); + + try { + await writeFile( + join(tmpDir, "old-agent.jsonc"), + JSON.stringify({ + name: "legacy", + description: "Old agent", + instructions: "Do old things", + }) + ); + await writeFile( + join(tmpDir, "helper.jsonc"), + JSON.stringify({ + name: "support", + description: "Help desk", + instructions: "Be helpful", + }) + ); + + const remoteAgents: AgentConfigApiResponse[] = [ + { + name: "support", + description: "Help desk", + instructions: "Be helpful", + }, + ]; + + const { written, deleted } = await writeAgents(tmpDir, remoteAgents); + + expect(written).toEqual([]); + expect(deleted).toEqual(["legacy"]); + + const files = await readdir(tmpDir); + expect(files).toEqual(["helper.jsonc"]); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("skips writing when data is unchanged, preserving comments and formatting", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "agents-test-")); + + try { + const fileContent = `// My support agent\n{\n "name": "support",\n "description": "Help desk",\n "instructions": "Be helpful"\n}\n`; + await writeFile(join(tmpDir, "support.jsonc"), fileContent); + + const remoteAgents: AgentConfigApiResponse[] = [ + { + name: "support", + description: "Help desk", + instructions: "Be helpful", + }, + ]; + + const { written, deleted } = await writeAgents(tmpDir, remoteAgents); + + expect(written).toEqual([]); + expect(deleted).toEqual([]); + + const rawContent = await readFile(join(tmpDir, "support.jsonc"), "utf-8"); + expect(rawContent).toBe(fileContent); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("writes when data has changed even if file has comments", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "agents-test-")); + + try { + const fileContent = `// My support agent\n{\n "name": "support",\n "description": "Help desk",\n "instructions": "Be helpful"\n}\n`; + await writeFile(join(tmpDir, "support.jsonc"), fileContent); + + const remoteAgents: AgentConfigApiResponse[] = [ + { + name: "support", + description: "Updated help desk", + instructions: "Be very helpful", + }, + ]; + + const { written, deleted } = await writeAgents(tmpDir, remoteAgents); + + expect(written).toEqual(["support"]); + expect(deleted).toEqual([]); + + const content = JSON.parse( + await readFile(join(tmpDir, "support.jsonc"), "utf-8") + ); + expect(content.description).toBe("Updated help desk"); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } + }); +});