Skip to content
Merged
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
2 changes: 2 additions & 0 deletions examples/lobu-crm/lobu.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import {
defineRelationshipType,
defineWatcher,
secret,
skillFromFile,
} from "@lobu/cli/config";

const crm = defineAgent({
id: "crm",
name: "crm",
description:
"Maintains Lobu's funnel CRM — leads, pilots, inbound triage, weekly digest",
skills: [skillFromFile("./agents/crm/skills/crm-ops")],
providers: [
{
id: "z-ai",
Expand Down
2 changes: 2 additions & 0 deletions examples/office-bot/lobu.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
defineEntityType,
defineWatcher,
secret,
skillFromFile,
} from "@lobu/cli/config";

const DELIVEROO_JUDGE =
Expand All @@ -15,6 +16,7 @@ const foodOrdering = defineAgent({
description:
"Runs the office lunch order — presence check, recommendations, options poll, order collection, Deliveroo basket handoff",
dir: "./agents/food-ordering",
skills: [skillFromFile("./agents/food-ordering/skills/deliveroo-order")],
providers: [
{
id: "z-ai",
Expand Down
148 changes: 119 additions & 29 deletions packages/cli/src/commands/_lib/apply/__tests__/load-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ describe("loadDesiredStateFromConfig", () => {
]);
});

test("loads agent-dir markdown + skills and merges skill network config", async () => {
test("loads agent-dir markdown + a file skill, merging skill network/nix", async () => {
dir = mkdtempSync(join(import.meta.dir, "agentdir-"));
const agentDir = join(dir, "agents", "crm");
mkdirSync(join(agentDir, "skills", "crm-ops"), { recursive: true });
Expand All @@ -177,9 +177,15 @@ describe("loadDesiredStateFromConfig", () => {
writeFileSync(
join(dir, "lobu.config.ts"),
[
`import { defineAgent, defineConfig } from "@lobu/cli/config";`,
`import { defineAgent, defineConfig, skillFromFile } from "@lobu/cli/config";`,
`export default defineConfig({`,
` agents: [defineAgent({ id: "crm", network: { allowed: ["github.com"] } })],`,
` agents: [`,
` defineAgent({`,
` id: "crm",`,
` network: { allowed: ["github.com"] },`,
` skills: [skillFromFile("./agents/crm/skills/crm-ops")],`,
` }),`,
` ],`,
`});`,
``,
].join("\n")
Expand All @@ -198,19 +204,71 @@ describe("loadDesiredStateFromConfig", () => {
expect(settings?.nixConfig?.packages).toEqual(["jq"]);
});

test("two agents: custom + default dirs keep index alignment; project ./skills applies to all", async () => {
test("an inline defineSkill carries content + network with no files", async () => {
dir = mkdtempSync(join(import.meta.dir, "inline-"));
writeFileSync(
join(dir, "lobu.config.ts"),
[
`import { defineAgent, defineConfig, defineSkill } from "@lobu/cli/config";`,
`const greet = defineSkill({`,
` name: "greet",`,
` description: "Greet someone.",`,
` content: "Generate a warm greeting.",`,
` network: { allowed: ["api.greet.com"] },`,
`});`,
`export default defineConfig({`,
` agents: [defineAgent({ id: "a", skills: [greet] })],`,
`});`,
``,
].join("\n")
);

const { state } = await loadDesiredStateFromConfig({ cwd: dir });
const skill = state.agents[0]?.settings.skillsConfig?.skills[0];
expect(skill?.name).toBe("greet");
expect(skill?.content).toBe("Generate a warm greeting.");
expect(skill?.description).toBe("Greet someone.");
expect(state.agents[0]?.settings.networkConfig?.allowedDomains).toEqual([
"api.greet.com",
]);
});

test("an inline skill MCP server merges into agent mcpServers", async () => {
dir = mkdtempSync(join(import.meta.dir, "skillmcp-"));
writeFileSync(
join(dir, "lobu.config.ts"),
[
`import { defineAgent, defineConfig, defineSkill } from "@lobu/cli/config";`,
`const api = defineSkill({`,
` name: "api",`,
` content: "Use the API.",`,
` mcpServers: { "support-api": { url: "https://api.example.com/mcp", type: "sse" } },`,
`});`,
`export default defineConfig({`,
` agents: [defineAgent({ id: "a", skills: [api] })],`,
`});`,
``,
].join("\n")
);

const { state } = await loadDesiredStateFromConfig({ cwd: dir });
const mcp = (state.agents[0]?.settings.mcpServers ?? {}) as Record<
string,
{ url?: string; type?: string }
>;
expect(mcp["support-api"]).toEqual({
url: "https://api.example.com/mcp",
type: "sse",
});
});

test("two agents: custom + default dirs keep index alignment", async () => {
dir = mkdtempSync(join(import.meta.dir, "multiagent-"));
// Agent "a" uses a custom dir; agent "b" uses the default ./agents/b.
mkdirSync(join(dir, "custom-a"), { recursive: true });
mkdirSync(join(dir, "agents", "b"), { recursive: true });
writeFileSync(join(dir, "custom-a", "SOUL.md"), "Agent A soul.\n");
writeFileSync(join(dir, "agents", "b", "SOUL.md"), "Agent B soul.\n");
// Project-level shared skill (applies to every agent).
mkdirSync(join(dir, "skills", "shared"), { recursive: true });
writeFileSync(
join(dir, "skills", "shared", "SKILL.md"),
"---\nname: shared\n---\nShared.\n"
);
writeFileSync(
join(dir, "lobu.config.ts"),
[
Expand All @@ -231,7 +289,31 @@ describe("loadDesiredStateFromConfig", () => {
expect(state.agents[0]?.settings.soulMd).toBe("Agent A soul.");
expect(state.agents[1]?.metadata.agentId).toBe("b");
expect(state.agents[1]?.settings.soulMd).toBe("Agent B soul.");
// The project-level skill is merged into both agents.
});

test("a skill shared by two agents via skillFromFile lands on both", async () => {
dir = mkdtempSync(join(import.meta.dir, "shared-"));
mkdirSync(join(dir, "skills", "shared"), { recursive: true });
writeFileSync(
join(dir, "skills", "shared", "SKILL.md"),
"---\nname: shared\n---\nShared.\n"
);
writeFileSync(
join(dir, "lobu.config.ts"),
[
`import { defineAgent, defineConfig, skillFromFile } from "@lobu/cli/config";`,
`const shared = skillFromFile("./skills/shared");`,
`export default defineConfig({`,
` agents: [`,
` defineAgent({ id: "a", skills: [shared] }),`,
` defineAgent({ id: "b", skills: [shared] }),`,
` ],`,
`});`,
``,
].join("\n")
);

const { state } = await loadDesiredStateFromConfig({ cwd: dir });
expect(state.agents[0]?.settings.skillsConfig?.skills[0]?.name).toBe(
"shared"
);
Expand All @@ -240,33 +322,41 @@ describe("loadDesiredStateFromConfig", () => {
);
});

test("agent-dir skill overrides a project skill of the same name", async () => {
dir = mkdtempSync(join(import.meta.dir, "skilloverride-"));
mkdirSync(join(dir, "skills", "ops"), { recursive: true });
mkdirSync(join(dir, "agents", "a", "skills", "ops"), { recursive: true });
test("rejects duplicate skill names within an agent", async () => {
dir = mkdtempSync(join(import.meta.dir, "dup-"));
writeFileSync(
join(dir, "skills", "ops", "SKILL.md"),
"---\nname: ops\n---\nProject ops.\n"
join(dir, "lobu.config.ts"),
[
`import { defineAgent, defineConfig, defineSkill } from "@lobu/cli/config";`,
`export default defineConfig({`,
` agents: [defineAgent({ id: "a", skills: [`,
` defineSkill({ name: "ops", content: "one" }),`,
` defineSkill({ name: "ops", content: "two" }),`,
` ] })],`,
`});`,
``,
].join("\n")
);
writeFileSync(
join(dir, "agents", "a", "skills", "ops", "SKILL.md"),
"---\nname: ops\n---\nAgent ops.\n"
await expect(loadDesiredStateFromConfig({ cwd: dir })).rejects.toThrow(
/duplicate skill "ops"/
);
});

test("skillFromFile with a missing SKILL.md fails clearly", async () => {
dir = mkdtempSync(join(import.meta.dir, "missing-"));
writeFileSync(
join(dir, "lobu.config.ts"),
[
`import { defineAgent, defineConfig } from "@lobu/cli/config";`,
`export default defineConfig({ agents: [defineAgent({ id: "a" })] });`,
`import { defineAgent, defineConfig, skillFromFile } from "@lobu/cli/config";`,
`export default defineConfig({`,
` agents: [defineAgent({ id: "a", skills: [skillFromFile("./nope")] })],`,
`});`,
``,
].join("\n")
);

const { state } = await loadDesiredStateFromConfig({ cwd: dir });
const skills = state.agents[0]?.settings.skillsConfig?.skills;
// loadSkillFiles reads [./skills, <agentDir>/skills] in order, deduping by
// name — the agent-dir skill (read last) wins.
expect(skills).toHaveLength(1);
expect(skills?.[0]?.content).toBe("Agent ops.");
await expect(loadDesiredStateFromConfig({ cwd: dir })).rejects.toThrow(
/no SKILL\.md found/
);
});

test("loads a watcher reaction script (raw source) referenced by path", async () => {
Expand Down
Loading
Loading