feat: add agent avatar support (#1329) (thanks @dlauer)

This commit is contained in:
Peter Steinberger
2026-01-22 03:54:31 +00:00
parent 7edc464b82
commit a2bea8e366
25 changed files with 547 additions and 84 deletions

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { identityHasValues, parseIdentityMarkdown } from "../agents/identity-file.js";
import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js";
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
import type { IdentityConfig } from "../config/types.js";
@@ -15,7 +16,6 @@ import {
findAgentEntryIndex,
listAgentEntries,
loadAgentIdentity,
parseIdentityMarkdown,
} from "./agents.config.js";
type AgentsSetIdentityOptions = {
@@ -25,6 +25,7 @@ type AgentsSetIdentityOptions = {
name?: string;
emoji?: string;
theme?: string;
avatar?: string;
fromIdentity?: boolean;
json?: boolean;
};
@@ -40,7 +41,7 @@ async function loadIdentityFromFile(filePath: string): Promise<AgentIdentity | n
try {
const content = await fs.readFile(filePath, "utf-8");
const parsed = parseIdentityMarkdown(content);
if (!parsed.name && !parsed.emoji && !parsed.theme && !parsed.creature && !parsed.vibe) {
if (!identityHasValues(parsed)) {
return null;
}
return parsed;
@@ -75,7 +76,8 @@ export async function agentsSetIdentityCommand(
const nameRaw = coerceTrimmed(opts.name);
const emojiRaw = coerceTrimmed(opts.emoji);
const themeRaw = coerceTrimmed(opts.theme);
const hasExplicitIdentity = Boolean(nameRaw || emojiRaw || themeRaw);
const avatarRaw = coerceTrimmed(opts.avatar);
const hasExplicitIdentity = Boolean(nameRaw || emojiRaw || themeRaw || avatarRaw);
const identityFileRaw = coerceTrimmed(opts.identityFile);
const workspaceRaw = coerceTrimmed(opts.workspace);
@@ -141,10 +143,20 @@ export async function agentsSetIdentityCommand(
...(nameRaw || identityFromFile?.name ? { name: nameRaw ?? identityFromFile?.name } : {}),
...(emojiRaw || identityFromFile?.emoji ? { emoji: emojiRaw ?? identityFromFile?.emoji } : {}),
...(themeRaw || fileTheme ? { theme: themeRaw ?? fileTheme } : {}),
...(avatarRaw || identityFromFile?.avatar
? { avatar: avatarRaw ?? identityFromFile?.avatar }
: {}),
};
if (!incomingIdentity.name && !incomingIdentity.emoji && !incomingIdentity.theme) {
runtime.error("No identity fields provided. Use --name/--emoji/--theme or --from-identity.");
if (
!incomingIdentity.name &&
!incomingIdentity.emoji &&
!incomingIdentity.theme &&
!incomingIdentity.avatar
) {
runtime.error(
"No identity fields provided. Use --name/--emoji/--theme/--avatar or --from-identity.",
);
runtime.exit(1);
return;
}
@@ -204,5 +216,6 @@ export async function agentsSetIdentityCommand(
if (nextIdentity.name) runtime.log(`Name: ${nextIdentity.name}`);
if (nextIdentity.theme) runtime.log(`Theme: ${nextIdentity.theme}`);
if (nextIdentity.emoji) runtime.log(`Emoji: ${nextIdentity.emoji}`);
if (nextIdentity.avatar) runtime.log(`Avatar: ${nextIdentity.avatar}`);
if (workspaceDir) runtime.log(`Workspace: ${workspaceDir}`);
}

View File

@@ -1,12 +1,14 @@
import fs from "node:fs";
import path from "node:path";
import {
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js";
import type { AgentIdentityFile } from "../agents/identity-file.js";
import {
identityHasValues,
loadAgentIdentityFromWorkspace,
parseIdentityMarkdown as parseIdentityMarkdownFile,
} from "../agents/identity-file.js";
import type { ClawdbotConfig } from "../config/config.js";
import { normalizeAgentId } from "../routing/session-key.js";
@@ -28,14 +30,7 @@ export type AgentSummary = {
type AgentEntry = NonNullable<NonNullable<ClawdbotConfig["agents"]>["list"]>[number];
export type AgentIdentity = {
name?: string;
emoji?: string;
creature?: string;
vibe?: string;
theme?: string;
avatar?: string;
};
export type AgentIdentity = AgentIdentityFile;
export function listAgentEntries(cfg: ClawdbotConfig): AgentEntry[] {
const list = cfg.agents?.list;
@@ -74,47 +69,13 @@ function resolveAgentModel(cfg: ClawdbotConfig, agentId: string) {
}
export function parseIdentityMarkdown(content: string): AgentIdentity {
const identity: AgentIdentity = {};
const lines = content.split(/\r?\n/);
for (const line of lines) {
const cleaned = line.trim().replace(/^\s*-\s*/, "");
const colonIndex = cleaned.indexOf(":");
if (colonIndex === -1) continue;
const label = cleaned.slice(0, colonIndex).replace(/[*_]/g, "").trim().toLowerCase();
const value = cleaned
.slice(colonIndex + 1)
.replace(/^[*_]+|[*_]+$/g, "")
.trim();
if (!value) continue;
if (label === "name") identity.name = value;
if (label === "emoji") identity.emoji = value;
if (label === "creature") identity.creature = value;
if (label === "vibe") identity.vibe = value;
if (label === "theme") identity.theme = value;
if (label === "avatar") identity.avatar = value;
}
return identity;
return parseIdentityMarkdownFile(content);
}
export function loadAgentIdentity(workspace: string): AgentIdentity | null {
const identityPath = path.join(workspace, DEFAULT_IDENTITY_FILENAME);
try {
const content = fs.readFileSync(identityPath, "utf-8");
const parsed = parseIdentityMarkdown(content);
if (
!parsed.name &&
!parsed.emoji &&
!parsed.theme &&
!parsed.creature &&
!parsed.vibe &&
!parsed.avatar
) {
return null;
}
return parsed;
} catch {
return null;
}
const parsed = loadAgentIdentityFromWorkspace(workspace);
if (!parsed) return null;
return identityHasValues(parsed) ? parsed : null;
}
export function buildAgentSummaries(cfg: ClawdbotConfig): AgentSummary[] {

View File

@@ -54,7 +54,13 @@ describe("agents set-identity command", () => {
await fs.mkdir(workspace, { recursive: true });
await fs.writeFile(
path.join(workspace, "IDENTITY.md"),
["- Name: Clawd", "- Creature: helpful sloth", "- Emoji: :)", ""].join("\n"),
[
"- Name: Clawd",
"- Creature: helpful sloth",
"- Emoji: :)",
"- Avatar: avatars/clawd.png",
"",
].join("\n"),
"utf-8",
);
@@ -81,6 +87,7 @@ describe("agents set-identity command", () => {
name: "Clawd",
theme: "helpful sloth",
emoji: ":)",
avatar: "avatars/clawd.png",
});
});
@@ -115,7 +122,13 @@ describe("agents set-identity command", () => {
await fs.mkdir(workspace, { recursive: true });
await fs.writeFile(
path.join(workspace, "IDENTITY.md"),
["- Name: Clawd", "- Theme: space lobster", "- Emoji: :)", ""].join("\n"),
[
"- Name: Clawd",
"- Theme: space lobster",
"- Emoji: :)",
"- Avatar: avatars/base.png",
"",
].join("\n"),
"utf-8",
);
@@ -125,7 +138,7 @@ describe("agents set-identity command", () => {
});
await agentsSetIdentityCommand(
{ workspace, fromIdentity: true, name: "Nova", emoji: "🦞" },
{ workspace, fromIdentity: true, name: "Nova", emoji: "🦞", avatar: "avatars/custom.png" },
runtime,
);
@@ -137,6 +150,7 @@ describe("agents set-identity command", () => {
name: "Nova",
theme: "space lobster",
emoji: "🦞",
avatar: "avatars/custom.png",
});
});
@@ -147,9 +161,13 @@ describe("agents set-identity command", () => {
await fs.mkdir(workspace, { recursive: true });
await fs.writeFile(
identityPath,
["- **Name:** C-3PO", "- **Creature:** Flustered Protocol Droid", "- **Emoji:** 🤖", ""].join(
"\n",
),
[
"- **Name:** C-3PO",
"- **Creature:** Flustered Protocol Droid",
"- **Emoji:** 🤖",
"- **Avatar:** avatars/c3po.png",
"",
].join("\n"),
"utf-8",
);
@@ -168,6 +186,33 @@ describe("agents set-identity command", () => {
name: "C-3PO",
theme: "Flustered Protocol Droid",
emoji: "🤖",
avatar: "avatars/c3po.png",
});
});
it("accepts avatar-only identity from IDENTITY.md", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-identity-"));
const workspace = path.join(root, "work");
await fs.mkdir(workspace, { recursive: true });
await fs.writeFile(
path.join(workspace, "IDENTITY.md"),
"- Avatar: avatars/only.png\n",
"utf-8",
);
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,
config: { agents: { list: [{ id: "main", workspace }] } },
});
await agentsSetIdentityCommand({ workspace, fromIdentity: true }, runtime);
const written = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
agents?: { list?: Array<{ id: string; identity?: Record<string, string> }> };
};
const main = written.agents?.list?.find((entry) => entry.id === "main");
expect(main?.identity).toEqual({
avatar: "avatars/only.png",
});
});