Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions src/cli/commands/agents/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,14 @@ async function pullAgentsAction(): Promise<RunCommandResult> {
}
);

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",
}
);

Expand All @@ -45,6 +41,9 @@ async function pullAgentsAction(): Promise<RunCommandResult> {
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}`,
Expand Down
79 changes: 54 additions & 25 deletions src/core/resources/agent/config.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -33,9 +34,11 @@ export function generateAgentConfigContent(name: string): string {
`;
}

async function readAgentFile(agentPath: string): Promise<AgentConfig> {
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(
Expand All @@ -45,10 +48,16 @@ async function readAgentFile(agentPath: string): Promise<AgentConfig> {
);
}

return result.data;
return { data: result.data, raw };
}

export async function readAllAgents(agentsDir: string): Promise<AgentConfig[]> {
interface AgentFileEntry {
data: AgentConfig;
raw: unknown;
filePath: string;
}

async function readAgentFiles(agentsDir: string): Promise<AgentFileEntry[]> {
if (!(await pathExists(agentsDir))) {
return [];
}
Expand All @@ -58,46 +67,66 @@ export async function readAllAgents(agentsDir: string): Promise<AgentConfig[]> {
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<AgentConfig[]> {
const entries = await readAgentFiles(agentsDir);

const names = new Set<string>();
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<string, AgentFileEntry>();
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 };
}
4 changes: 2 additions & 2 deletions tests/cli/agents_pull.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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");
});

Expand Down
206 changes: 206 additions & 0 deletions tests/core/agents_write.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
Loading