diff --git a/CHANGELOG.md b/CHANGELOG.md index 487712a07..9533bd945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.clawd.bot ### Fixes - UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba. +- Agents: add `clawdbot agents set-identity` helper and update bootstrap guidance for multi-agent setups. (#1222) — thanks @ThePickle31. - Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context. - Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058) - Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084) diff --git a/docs/cli/agents.md b/docs/cli/agents.md index bf9668717..fd8b81d2c 100644 --- a/docs/cli/agents.md +++ b/docs/cli/agents.md @@ -1,5 +1,5 @@ --- -summary: "CLI reference for `clawdbot agents` (list/add/delete isolated agents)" +summary: "CLI reference for `clawdbot agents` (list/add/delete/set identity)" read_when: - You want multiple isolated agents (workspaces + routing + auth) --- @@ -17,6 +17,6 @@ Related: ```bash clawdbot agents list clawdbot agents add work --workspace ~/clawd-work +clawdbot agents set-identity --workspace ~/clawd --from-identity clawdbot agents delete work ``` - diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index 8ff2ffa1d..c48c8a204 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -135,7 +135,8 @@ After the user chooses, update: - Notes 3) ~/.clawdbot/clawdbot.json -Set identity.name, identity.theme, identity.emoji to match IDENTITY.md. +Run: clawdbot agents set-identity --workspace "" --from-identity +If multiple agents share a host, add --agent . ## Cleanup Delete BOOTSTRAP.md once this is complete. diff --git a/src/cli/program/register.agent.ts b/src/cli/program/register.agent.ts index e87b71452..7cbb63e2f 100644 --- a/src/cli/program/register.agent.ts +++ b/src/cli/program/register.agent.ts @@ -1,7 +1,12 @@ import type { Command } from "commander"; import { DEFAULT_CHAT_CHANNEL } from "../../channels/registry.js"; import { agentCliCommand } from "../../commands/agent-via-gateway.js"; -import { agentsAddCommand, agentsDeleteCommand, agentsListCommand } from "../../commands/agents.js"; +import { + agentsAddCommand, + agentsDeleteCommand, + agentsListCommand, + agentsSetIdentityCommand, +} from "../../commands/agents.js"; import { setVerbose } from "../../globals.js"; import { defaultRuntime } from "../../runtime.js"; import { formatDocsLink } from "../../terminal/links.js"; @@ -120,6 +125,45 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent }); }); + agents + .command("set-identity") + .description("Update an agent identity (name/theme/emoji)") + .option("--agent ", "Agent id to update") + .option("--workspace ", "Workspace directory used to locate the agent + IDENTITY.md") + .option("--identity-file ", "Explicit IDENTITY.md path to read") + .option("--from-identity", "Read values from IDENTITY.md", false) + .option("--name ", "Identity name") + .option("--theme ", "Identity theme") + .option("--emoji ", "Identity emoji") + .option("--json", "Output JSON summary", false) + .addHelpText( + "after", + () => + ` +Examples: + clawdbot agents set-identity --agent main --name "Clawd" --emoji "🦞" + clawdbot agents set-identity --workspace ~/clawd --from-identity + clawdbot agents set-identity --identity-file ~/clawd/IDENTITY.md --agent main +`, + ) + .action(async (opts) => { + await runCommandWithRuntime(defaultRuntime, async () => { + await agentsSetIdentityCommand( + { + agent: opts.agent as string | undefined, + workspace: opts.workspace as string | undefined, + identityFile: opts.identityFile as string | undefined, + fromIdentity: Boolean(opts.fromIdentity), + name: opts.name as string | undefined, + theme: opts.theme as string | undefined, + emoji: opts.emoji as string | undefined, + json: Boolean(opts.json), + }, + defaultRuntime, + ); + }); + }); + agents .command("delete ") .description("Delete an agent and prune workspace/state") diff --git a/src/commands/agents.commands.identity.ts b/src/commands/agents.commands.identity.ts new file mode 100644 index 000000000..38efffed6 --- /dev/null +++ b/src/commands/agents.commands.identity.ts @@ -0,0 +1,209 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.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"; +import { normalizeAgentId } from "../routing/session-key.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { defaultRuntime } from "../runtime.js"; +import { resolveUserPath } from "../utils.js"; +import { requireValidConfig } from "./agents.command-shared.js"; +import { + type AgentIdentity, + findAgentEntryIndex, + listAgentEntries, + loadAgentIdentity, + parseIdentityMarkdown, +} from "./agents.config.js"; + +type AgentsSetIdentityOptions = { + agent?: string; + workspace?: string; + identityFile?: string; + name?: string; + emoji?: string; + theme?: string; + fromIdentity?: boolean; + json?: boolean; +}; + +const normalizeWorkspacePath = (input: string) => path.resolve(resolveUserPath(input)); + +const coerceTrimmed = (value?: string) => { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +}; + +async function loadIdentityFromFile(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, "utf-8"); + const parsed = parseIdentityMarkdown(content); + if (!parsed.name && !parsed.emoji && !parsed.theme && !parsed.creature && !parsed.vibe) { + return null; + } + return parsed; + } catch { + return null; + } +} + +function resolveAgentIdByWorkspace( + cfg: Parameters[0], + workspaceDir: string, +): string[] { + const list = listAgentEntries(cfg); + const ids = + list.length > 0 ? list.map((entry) => normalizeAgentId(entry.id)) : [resolveDefaultAgentId(cfg)]; + const normalizedTarget = normalizeWorkspacePath(workspaceDir); + return ids.filter( + (id) => normalizeWorkspacePath(resolveAgentWorkspaceDir(cfg, id)) === normalizedTarget, + ); +} + +export async function agentsSetIdentityCommand( + opts: AgentsSetIdentityOptions, + runtime: RuntimeEnv = defaultRuntime, +) { + const cfg = await requireValidConfig(runtime); + if (!cfg) return; + + const agentRaw = coerceTrimmed(opts.agent); + const nameRaw = coerceTrimmed(opts.name); + const emojiRaw = coerceTrimmed(opts.emoji); + const themeRaw = coerceTrimmed(opts.theme); + const hasExplicitIdentity = Boolean(nameRaw || emojiRaw || themeRaw); + + const identityFileRaw = coerceTrimmed(opts.identityFile); + const workspaceRaw = coerceTrimmed(opts.workspace); + const wantsIdentityFile = Boolean(opts.fromIdentity || identityFileRaw || !hasExplicitIdentity); + + let identityFilePath: string | undefined; + let workspaceDir: string | undefined; + + if (identityFileRaw) { + identityFilePath = normalizeWorkspacePath(identityFileRaw); + workspaceDir = path.dirname(identityFilePath); + } else if (workspaceRaw) { + workspaceDir = normalizeWorkspacePath(workspaceRaw); + } else if (wantsIdentityFile || !agentRaw) { + workspaceDir = path.resolve(process.cwd()); + } + + let agentId = agentRaw ? normalizeAgentId(agentRaw) : undefined; + if (!agentId) { + if (!workspaceDir) { + runtime.error("Select an agent with --agent or provide a workspace via --workspace."); + runtime.exit(1); + return; + } + const matches = resolveAgentIdByWorkspace(cfg, workspaceDir); + if (matches.length === 0) { + runtime.error( + `No agent workspace matches ${workspaceDir}. Pass --agent to target a specific agent.`, + ); + runtime.exit(1); + return; + } + if (matches.length > 1) { + runtime.error( + `Multiple agents match ${workspaceDir}: ${matches.join(", ")}. Pass --agent to choose one.`, + ); + runtime.exit(1); + return; + } + agentId = matches[0]; + } + + let identityFromFile: AgentIdentity | null = null; + if (wantsIdentityFile) { + if (identityFilePath) { + identityFromFile = await loadIdentityFromFile(identityFilePath); + } else if (workspaceDir) { + identityFromFile = loadAgentIdentity(workspaceDir); + } + if (!identityFromFile) { + const targetPath = + identityFilePath ?? + (workspaceDir ? path.join(workspaceDir, DEFAULT_IDENTITY_FILENAME) : "IDENTITY.md"); + runtime.error(`No identity data found in ${targetPath}.`); + runtime.exit(1); + return; + } + } + + const fileTheme = + identityFromFile?.theme ?? + identityFromFile?.creature ?? + identityFromFile?.vibe ?? + undefined; + const incomingIdentity: IdentityConfig = { + ...(nameRaw || identityFromFile?.name ? { name: nameRaw ?? identityFromFile?.name } : {}), + ...(emojiRaw || identityFromFile?.emoji ? { emoji: emojiRaw ?? identityFromFile?.emoji } : {}), + ...(themeRaw || fileTheme ? { theme: themeRaw ?? fileTheme } : {}), + }; + + if (!incomingIdentity.name && !incomingIdentity.emoji && !incomingIdentity.theme) { + runtime.error("No identity fields provided. Use --name/--emoji/--theme or --from-identity."); + runtime.exit(1); + return; + } + + const list = listAgentEntries(cfg); + const index = findAgentEntryIndex(list, agentId); + const base = index >= 0 ? list[index] : { id: agentId }; + const nextIdentity: IdentityConfig = { + ...base.identity, + ...incomingIdentity, + }; + + const nextEntry = { + ...base, + identity: nextIdentity, + }; + + const nextList = [...list]; + if (index >= 0) { + nextList[index] = nextEntry; + } else { + const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg)); + if (nextList.length === 0 && agentId !== defaultId) { + nextList.push({ id: defaultId }); + } + nextList.push(nextEntry); + } + + const nextConfig = { + ...cfg, + agents: { + ...cfg.agents, + list: nextList, + }, + }; + + await writeConfigFile(nextConfig); + + if (opts.json) { + runtime.log( + JSON.stringify( + { + agentId, + identity: nextIdentity, + workspace: workspaceDir ?? null, + identityFile: identityFilePath ?? null, + }, + null, + 2, + ), + ); + return; + } + + runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); + runtime.log(`Agent: ${agentId}`); + 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 (workspaceDir) runtime.log(`Workspace: ${workspaceDir}`); +} diff --git a/src/commands/agents.config.ts b/src/commands/agents.config.ts index 80a7e87ef..d973842c3 100644 --- a/src/commands/agents.config.ts +++ b/src/commands/agents.config.ts @@ -28,11 +28,12 @@ export type AgentSummary = { type AgentEntry = NonNullable["list"]>[number]; -type AgentIdentity = { +export type AgentIdentity = { name?: string; emoji?: string; creature?: string; vibe?: string; + theme?: string; }; export function listAgentEntries(cfg: ClawdbotConfig): AgentEntry[] { @@ -71,29 +72,40 @@ function resolveAgentModel(cfg: ClawdbotConfig, agentId: string) { return raw?.primary?.trim() || undefined; } -function parseIdentityMarkdown(content: string): AgentIdentity { +export function parseIdentityMarkdown(content: string): AgentIdentity { const identity: AgentIdentity = {}; const lines = content.split(/\r?\n/); for (const line of lines) { - const match = line.match(/^\s*(?:-\s*)?([A-Za-z ]+):\s*(.+?)\s*$/); - if (!match) continue; - const label = match[1]?.trim().toLowerCase(); - const value = match[2]?.trim(); + 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; } return identity; } -function loadAgentIdentity(workspace: string): AgentIdentity | null { +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) return null; + if (!parsed.name && !parsed.emoji && !parsed.theme && !parsed.creature && !parsed.vibe) { + return null; + } return parsed; } catch { return null; diff --git a/src/commands/agents.identity.test.ts b/src/commands/agents.identity.test.ts new file mode 100644 index 000000000..581a5c0d6 --- /dev/null +++ b/src/commands/agents.identity.test.ts @@ -0,0 +1,185 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { RuntimeEnv } from "../runtime.js"; + +const configMocks = vi.hoisted(() => ({ + readConfigFileSnapshot: vi.fn(), + writeConfigFile: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readConfigFileSnapshot: configMocks.readConfigFileSnapshot, + writeConfigFile: configMocks.writeConfigFile, + }; +}); + +import { agentsSetIdentityCommand } from "./agents.js"; + +const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +}; + +const baseSnapshot = { + path: "/tmp/clawdbot.json", + exists: true, + raw: "{}", + parsed: {}, + valid: true, + config: {}, + issues: [], + legacyIssues: [], +}; + +describe("agents set-identity command", () => { + beforeEach(() => { + configMocks.readConfigFileSnapshot.mockReset(); + configMocks.writeConfigFile.mockClear(); + runtime.log.mockClear(); + runtime.error.mockClear(); + runtime.exit.mockClear(); + }); + + it("sets identity from workspace 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"), + ["- Name: Clawd", "- Creature: helpful sloth", "- Emoji: :)", ""].join("\n"), + "utf-8", + ); + + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseSnapshot, + config: { + agents: { + list: [ + { id: "main", workspace }, + { id: "ops", workspace: path.join(root, "ops") }, + ], + }, + }, + }); + + await agentsSetIdentityCommand({ workspace }, runtime); + + expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1); + const written = configMocks.writeConfigFile.mock.calls[0]?.[0] as { + agents?: { list?: Array<{ id: string; identity?: Record }> }; + }; + const main = written.agents?.list?.find((entry) => entry.id === "main"); + expect(main?.identity).toEqual({ + name: "Clawd", + theme: "helpful sloth", + emoji: ":)", + }); + }); + + it("errors when multiple agents match the same workspace", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-identity-")); + const workspace = path.join(root, "shared"); + await fs.mkdir(workspace, { recursive: true }); + await fs.writeFile(path.join(workspace, "IDENTITY.md"), "- Name: Echo\n", "utf-8"); + + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseSnapshot, + config: { + agents: { list: [{ id: "main", workspace }, { id: "ops", workspace }] }, + }, + }); + + await agentsSetIdentityCommand({ workspace }, runtime); + + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Multiple agents match")); + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(configMocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("overrides identity file values with explicit flags", 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"), + ["- Name: Clawd", "- Theme: space lobster", "- Emoji: :)", ""].join("\n"), + "utf-8", + ); + + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseSnapshot, + config: { agents: { list: [{ id: "main", workspace }] } }, + }); + + await agentsSetIdentityCommand( + { workspace, fromIdentity: true, name: "Nova", emoji: "🦞" }, + runtime, + ); + + const written = configMocks.writeConfigFile.mock.calls[0]?.[0] as { + agents?: { list?: Array<{ id: string; identity?: Record }> }; + }; + const main = written.agents?.list?.find((entry) => entry.id === "main"); + expect(main?.identity).toEqual({ + name: "Nova", + theme: "space lobster", + emoji: "🦞", + }); + }); + + it("reads identity from an explicit IDENTITY.md path", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-identity-")); + const workspace = path.join(root, "work"); + const identityPath = path.join(workspace, "IDENTITY.md"); + await fs.mkdir(workspace, { recursive: true }); + await fs.writeFile( + identityPath, + ["- **Name:** C-3PO", "- **Creature:** Flustered Protocol Droid", "- **Emoji:** 🤖", ""].join( + "\n", + ), + "utf-8", + ); + + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseSnapshot, + config: { agents: { list: [{ id: "main" }] } }, + }); + + await agentsSetIdentityCommand({ agent: "main", identityFile: identityPath }, runtime); + + const written = configMocks.writeConfigFile.mock.calls[0]?.[0] as { + agents?: { list?: Array<{ id: string; identity?: Record }> }; + }; + const main = written.agents?.list?.find((entry) => entry.id === "main"); + expect(main?.identity).toEqual({ + name: "C-3PO", + theme: "Flustered Protocol Droid", + emoji: "🤖", + }); + }); + + it("errors when identity data is missing", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-identity-")); + const workspace = path.join(root, "work"); + await fs.mkdir(workspace, { recursive: true }); + + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseSnapshot, + config: { agents: { list: [{ id: "main", workspace }] } }, + }); + + await agentsSetIdentityCommand({ workspace, fromIdentity: true }, runtime); + + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("No identity data found")); + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(configMocks.writeConfigFile).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/agents.ts b/src/commands/agents.ts index 46364e1e7..6679bb853 100644 --- a/src/commands/agents.ts +++ b/src/commands/agents.ts @@ -1,5 +1,6 @@ export * from "./agents.bindings.js"; export * from "./agents.commands.add.js"; export * from "./agents.commands.delete.js"; +export * from "./agents.commands.identity.js"; export * from "./agents.commands.list.js"; export * from "./agents.config.js";