From 57ac95b1727202058011e639224dfdc6e7afc94f Mon Sep 17 00:00:00 2001 From: Pavel Tarnopolsky Date: Wed, 11 Feb 2026 11:29:27 +0200 Subject: [PATCH 1/4] refactor(agents): improve writeAgents with file-level tracking and skip-unchanged Refactor writeAgents to track files by content name rather than filename, enabling correct updates when filenames don't match agent names. Skip writing unchanged data to preserve comments and formatting. Add duplicate name validation in write path for consistency with readAllAgents. Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/agents/pull.ts | 3 + src/core/resources/agent/config.ts | 81 +++++++++---- tests/core/agents_write.spec.ts | 178 +++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+), 25 deletions(-) create mode 100644 tests/core/agents_write.spec.ts diff --git a/src/cli/commands/agents/pull.ts b/src/cli/commands/agents/pull.ts index 282bec85..9c790164 100644 --- a/src/cli/commands/agents/pull.ts +++ b/src/cli/commands/agents/pull.ts @@ -45,6 +45,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..73ae8be6 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,18 @@ 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 +69,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/core/agents_write.spec.ts b/tests/core/agents_write.spec.ts new file mode 100644 index 00000000..d5bc54fe --- /dev/null +++ b/tests/core/agents_write.spec.ts @@ -0,0 +1,178 @@ +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 type { AgentConfigApiResponse } from "../../src/core/resources/agent/schema.js"; +import { + readAllAgents, + writeAgents, +} from "../../src/core/resources/agent/config.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 }); + } + }); +}); From 117bdcef96d947aebc3ba94316d83a448e79c0da Mon Sep 17 00:00:00 2001 From: Pavel Tarnopolsky Date: Wed, 11 Feb 2026 11:40:23 +0200 Subject: [PATCH 2/4] fix(agents): remove early return in pull command when remote is empty The early return skipped writeAgents() when remote had 0 agents, preventing deletion of stale local files. Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/agents/pull.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/cli/commands/agents/pull.ts b/src/cli/commands/agents/pull.ts index 9c790164..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", } ); From 67ecf18bb9925e0682aba4cfba4cb042d3b01bd2 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:54:36 +0000 Subject: [PATCH 3/4] style: apply biome lint fixes Co-authored-by: paveltarno --- src/core/resources/agent/config.ts | 4 +-- tests/core/agents_write.spec.ts | 44 ++++++++++++++++++++++++------ 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/core/resources/agent/config.ts b/src/core/resources/agent/config.ts index 73ae8be6..0d85b58d 100644 --- a/src/core/resources/agent/config.ts +++ b/src/core/resources/agent/config.ts @@ -57,9 +57,7 @@ interface AgentFileEntry { filePath: string; } -async function readAgentFiles( - agentsDir: string -): Promise { +async function readAgentFiles(agentsDir: string): Promise { if (!(await pathExists(agentsDir))) { return []; } diff --git a/tests/core/agents_write.spec.ts b/tests/core/agents_write.spec.ts index d5bc54fe..450fbb18 100644 --- a/tests/core/agents_write.spec.ts +++ b/tests/core/agents_write.spec.ts @@ -2,11 +2,11 @@ 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 type { AgentConfigApiResponse } from "../../src/core/resources/agent/schema.js"; 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 () => { @@ -14,7 +14,11 @@ describe("writeAgents", () => { try { const remoteAgents: AgentConfigApiResponse[] = [ - { name: "support", description: "Help desk", instructions: "Be helpful" }, + { + name: "support", + description: "Help desk", + instructions: "Be helpful", + }, { name: "sales", description: "Sales bot", instructions: "Sell stuff" }, ]; @@ -36,13 +40,21 @@ describe("writeAgents", () => { try { const initial: AgentConfigApiResponse[] = [ - { name: "support", description: "Help desk", instructions: "Be helpful" }, + { + 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" }, + { + name: "support", + description: "Help desk", + instructions: "Be helpful", + }, ]; const { written, deleted } = await writeAgents(tmpDir, remote); @@ -71,7 +83,11 @@ describe("writeAgents", () => { ); const remoteAgents: AgentConfigApiResponse[] = [ - { name: "support", description: "Updated help desk", instructions: "Be very helpful" }, + { + name: "support", + description: "Updated help desk", + instructions: "Be very helpful", + }, ]; const { written, deleted } = await writeAgents(tmpDir, remoteAgents); @@ -113,7 +129,11 @@ describe("writeAgents", () => { ); const remoteAgents: AgentConfigApiResponse[] = [ - { name: "support", description: "Help desk", instructions: "Be helpful" }, + { + name: "support", + description: "Help desk", + instructions: "Be helpful", + }, ]; const { written, deleted } = await writeAgents(tmpDir, remoteAgents); @@ -136,7 +156,11 @@ describe("writeAgents", () => { await writeFile(join(tmpDir, "support.jsonc"), fileContent); const remoteAgents: AgentConfigApiResponse[] = [ - { name: "support", description: "Help desk", instructions: "Be helpful" }, + { + name: "support", + description: "Help desk", + instructions: "Be helpful", + }, ]; const { written, deleted } = await writeAgents(tmpDir, remoteAgents); @@ -159,7 +183,11 @@ describe("writeAgents", () => { await writeFile(join(tmpDir, "support.jsonc"), fileContent); const remoteAgents: AgentConfigApiResponse[] = [ - { name: "support", description: "Updated help desk", instructions: "Be very helpful" }, + { + name: "support", + description: "Updated help desk", + instructions: "Be very helpful", + }, ]; const { written, deleted } = await writeAgents(tmpDir, remoteAgents); From c568c5df2b1bf3647fc3179361d9059eaf4ee2bd Mon Sep 17 00:00:00 2001 From: Pavel Tarnopolsky Date: Wed, 11 Feb 2026 11:56:45 +0200 Subject: [PATCH 4/4] fix(tests): update agents pull test expectations to match new messages Co-Authored-By: Claude Opus 4.6 --- tests/cli/agents_pull.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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"); });