Refactor setup and tool helpers for improved modularity and clarity

- Moved setup logic into dedicated files: agent.ts, config.ts, index.ts, workspace.ts.
- Introduced tool-helpers.ts for shared functions across tools, reducing boilerplate.
- Updated tools (status, task-comment, task-create, task-update, work-finish, work-start) to utilize new helper functions for workspace resolution and provider creation.
- Enhanced error handling and context detection in tools.
- Improved project resolution logic to streamline tool execution.
- Added new functionality for agent creation and configuration management in setup.
This commit is contained in:
Lauren ten Hoor
2026-02-10 22:51:35 +08:00
parent 55b062ac76
commit 70af40e986
22 changed files with 768 additions and 953 deletions

87
lib/setup/agent.ts Normal file
View File

@@ -0,0 +1,87 @@
/**
* setup/agent.ts — Agent creation and workspace resolution.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import fs from "node:fs/promises";
import path from "node:path";
const execFileAsync = promisify(execFile);
function openclawConfigPath(): string {
return path.join(process.env.HOME ?? "/home/lauren", ".openclaw", "openclaw.json");
}
/**
* Create a new agent via `openclaw agents add`.
* Cleans up .git and BOOTSTRAP.md from the workspace, updates display name.
*/
export async function createAgent(
name: string,
channelBinding?: "telegram" | "whatsapp" | null,
): Promise<{ agentId: string; workspacePath: string }> {
const agentId = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
const workspacePath = path.join(
process.env.HOME ?? "/home/lauren",
".openclaw",
`workspace-${agentId}`,
);
const args = ["agents", "add", agentId, "--workspace", workspacePath, "--non-interactive"];
if (channelBinding) args.push("--bind", channelBinding);
try {
await execFileAsync("openclaw", args, { timeout: 30_000 });
} catch (err) {
throw new Error(`Failed to create agent "${name}": ${(err as Error).message}`);
}
await cleanupWorkspace(workspacePath);
await updateAgentDisplayName(agentId, name);
return { agentId, workspacePath };
}
/**
* Resolve workspace path from an agent ID by reading openclaw.json.
*/
export async function resolveWorkspacePath(agentId: string): Promise<string> {
const raw = await fs.readFile(openclawConfigPath(), "utf-8");
const config = JSON.parse(raw);
const agent = config.agents?.list?.find((a: { id: string }) => a.id === agentId);
if (!agent?.workspace) {
throw new Error(`Agent "${agentId}" not found in openclaw.json or has no workspace configured.`);
}
return agent.workspace;
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
async function cleanupWorkspace(workspacePath: string): Promise<void> {
// openclaw agents add creates a .git dir and BOOTSTRAP.md — remove them
try { await fs.rm(path.join(workspacePath, ".git"), { recursive: true }); } catch { /* may not exist */ }
try { await fs.unlink(path.join(workspacePath, "BOOTSTRAP.md")); } catch { /* may not exist */ }
}
async function updateAgentDisplayName(agentId: string, name: string): Promise<void> {
if (name === agentId) return;
try {
const configPath = openclawConfigPath();
const config = JSON.parse(await fs.readFile(configPath, "utf-8"));
const agent = config.agents?.list?.find((a: { id: string }) => a.id === agentId);
if (agent) {
agent.name = name;
await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
}
} catch (err) {
console.warn(`Warning: Could not update display name: ${(err as Error).message}`);
}
}

88
lib/setup/config.ts Normal file
View File

@@ -0,0 +1,88 @@
/**
* setup/config.ts — Plugin config writer (openclaw.json).
*
* Handles: model tier config, devClawAgentIds, tool restrictions, subagent cleanup.
*/
import fs from "node:fs/promises";
import path from "node:path";
import type { Tier } from "../tiers.js";
function openclawConfigPath(): string {
return path.join(process.env.HOME ?? "/home/lauren", ".openclaw", "openclaw.json");
}
/**
* Write DevClaw model tier config and devClawAgentIds to openclaw.json plugins section.
*
* Also configures:
* - Tool restrictions (deny sessions_spawn, sessions_send) for DevClaw agents
* - Subagent cleanup interval (30 days) to keep development sessions alive
*
* Read-modify-write to preserve existing config.
*/
export async function writePluginConfig(
models: Record<Tier, string>,
agentId?: string,
projectExecution?: "parallel" | "sequential",
): Promise<void> {
const configPath = openclawConfigPath();
const config = JSON.parse(await fs.readFile(configPath, "utf-8"));
ensurePluginStructure(config);
config.plugins.entries.devclaw.config.models = { ...models };
if (projectExecution) {
config.plugins.entries.devclaw.config.projectExecution = projectExecution;
}
configureSubagentCleanup(config);
if (agentId) {
addDevClawAgentId(config, agentId);
addToolRestrictions(config, agentId);
}
const tmpPath = configPath + ".tmp";
await fs.writeFile(tmpPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
await fs.rename(tmpPath, configPath);
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
function ensurePluginStructure(config: Record<string, unknown>): void {
if (!config.plugins) config.plugins = {};
const plugins = config.plugins as Record<string, unknown>;
if (!plugins.entries) plugins.entries = {};
const entries = plugins.entries as Record<string, unknown>;
if (!entries.devclaw) entries.devclaw = {};
const devclaw = entries.devclaw as Record<string, unknown>;
if (!devclaw.config) devclaw.config = {};
}
function configureSubagentCleanup(config: Record<string, unknown>): void {
if (!config.agents) config.agents = {};
const agents = config.agents as Record<string, unknown>;
if (!agents.defaults) agents.defaults = {};
const defaults = agents.defaults as Record<string, unknown>;
if (!defaults.subagents) defaults.subagents = {};
(defaults.subagents as Record<string, unknown>).archiveAfterMinutes = 43200;
}
function addDevClawAgentId(config: Record<string, unknown>, agentId: string): void {
const devclaw = (config as any).plugins.entries.devclaw.config;
const existing: string[] = devclaw.devClawAgentIds ?? [];
if (!existing.includes(agentId)) {
devclaw.devClawAgentIds = [...existing, agentId];
}
}
function addToolRestrictions(config: Record<string, unknown>, agentId: string): void {
const agent = (config as any).agents?.list?.find((a: { id: string }) => a.id === agentId);
if (agent) {
if (!agent.tools) agent.tools = {};
agent.tools.deny = ["sessions_spawn", "sessions_send"];
delete agent.tools.allow;
}
}

120
lib/setup/index.ts Normal file
View File

@@ -0,0 +1,120 @@
/**
* setup/index.ts — DevClaw setup orchestrator.
*
* Coordinates: agent creation → model config → workspace scaffolding.
* Used by both the `setup` tool and the `openclaw devclaw setup` CLI command.
*/
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "../tiers.js";
import { migrateChannelBinding } from "../binding-manager.js";
import { createAgent, resolveWorkspacePath } from "./agent.js";
import { writePluginConfig } from "./config.js";
import { scaffoldWorkspace } from "./workspace.js";
export type SetupOpts = {
/** Create a new agent with this name. Mutually exclusive with agentId. */
newAgentName?: string;
/** Channel binding for new agent. Only used when newAgentName is set. */
channelBinding?: "telegram" | "whatsapp" | null;
/** Migrate channel binding from this agent ID. Only used when newAgentName and channelBinding are set. */
migrateFrom?: string;
/** Use an existing agent by ID. Mutually exclusive with newAgentName. */
agentId?: string;
/** Override workspace path (auto-detected from agent if not given). */
workspacePath?: string;
/** Model overrides per tier. Missing tiers use defaults. */
models?: Partial<Record<Tier, string>>;
/** Plugin-level project execution mode: parallel or sequential. Default: parallel. */
projectExecution?: "parallel" | "sequential";
};
export type SetupResult = {
agentId: string;
agentCreated: boolean;
workspacePath: string;
models: Record<Tier, string>;
filesWritten: string[];
warnings: string[];
bindingMigrated?: {
from: string;
channel: "telegram" | "whatsapp";
};
};
/**
* Run the full DevClaw setup.
*
* 1. Create agent (optional) or resolve existing workspace
* 2. Merge model config and write to openclaw.json
* 3. Write workspace files (AGENTS.md, HEARTBEAT.md, roles, memory)
*/
export async function runSetup(opts: SetupOpts): Promise<SetupResult> {
const warnings: string[] = [];
const { agentId, workspacePath, agentCreated, bindingMigrated } =
await resolveOrCreateAgent(opts, warnings);
const models = buildModelConfig(opts.models);
await writePluginConfig(models, agentId, opts.projectExecution);
const filesWritten = await scaffoldWorkspace(workspacePath);
return { agentId, agentCreated, workspacePath, models, filesWritten, warnings, bindingMigrated };
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
async function resolveOrCreateAgent(
opts: SetupOpts,
warnings: string[],
): Promise<{
agentId: string;
workspacePath: string;
agentCreated: boolean;
bindingMigrated?: SetupResult["bindingMigrated"];
}> {
if (opts.newAgentName) {
const { agentId, workspacePath } = await createAgent(opts.newAgentName, opts.channelBinding);
const bindingMigrated = await tryMigrateBinding(opts, agentId, warnings);
return { agentId, workspacePath, agentCreated: true, bindingMigrated };
}
if (opts.agentId) {
const workspacePath = opts.workspacePath ?? await resolveWorkspacePath(opts.agentId);
return { agentId: opts.agentId, workspacePath, agentCreated: false };
}
if (opts.workspacePath) {
return { agentId: "unknown", workspacePath: opts.workspacePath, agentCreated: false };
}
throw new Error("Setup requires either newAgentName, agentId, or workspacePath");
}
async function tryMigrateBinding(
opts: SetupOpts,
agentId: string,
warnings: string[],
): Promise<SetupResult["bindingMigrated"]> {
if (!opts.migrateFrom || !opts.channelBinding) return undefined;
try {
await migrateChannelBinding(opts.channelBinding, opts.migrateFrom, agentId);
return { from: opts.migrateFrom, channel: opts.channelBinding };
} catch (err) {
warnings.push(`Failed to migrate binding from "${opts.migrateFrom}": ${(err as Error).message}`);
return undefined;
}
}
function buildModelConfig(overrides?: Partial<Record<Tier, string>>): Record<Tier, string> {
const models = { ...DEFAULT_MODELS };
if (overrides) {
for (const [tier, model] of Object.entries(overrides)) {
if (model && (ALL_TIERS as readonly string[]).includes(tier)) {
models[tier as Tier] = model;
}
}
}
return models;
}

79
lib/setup/workspace.ts Normal file
View File

@@ -0,0 +1,79 @@
/**
* setup/workspace.ts — Workspace file scaffolding.
*
* Writes AGENTS.md, HEARTBEAT.md, default role instructions, and projects.json.
*/
import fs from "node:fs/promises";
import path from "node:path";
import {
AGENTS_MD_TEMPLATE,
HEARTBEAT_MD_TEMPLATE,
DEFAULT_DEV_INSTRUCTIONS,
DEFAULT_QA_INSTRUCTIONS,
} from "../templates.js";
/**
* Write all workspace files for a DevClaw agent.
* Returns the list of files that were written (skips files that already exist).
*/
export async function scaffoldWorkspace(workspacePath: string): Promise<string[]> {
const filesWritten: string[] = [];
// AGENTS.md (backup existing)
await backupAndWrite(path.join(workspacePath, "AGENTS.md"), AGENTS_MD_TEMPLATE);
filesWritten.push("AGENTS.md");
// HEARTBEAT.md
await backupAndWrite(path.join(workspacePath, "HEARTBEAT.md"), HEARTBEAT_MD_TEMPLATE);
filesWritten.push("HEARTBEAT.md");
// roles/default/dev.md and qa.md
const rolesDir = path.join(workspacePath, "roles", "default");
await fs.mkdir(rolesDir, { recursive: true });
const devRolePath = path.join(rolesDir, "dev.md");
if (!await fileExists(devRolePath)) {
await fs.writeFile(devRolePath, DEFAULT_DEV_INSTRUCTIONS, "utf-8");
filesWritten.push("roles/default/dev.md");
}
const qaRolePath = path.join(rolesDir, "qa.md");
if (!await fileExists(qaRolePath)) {
await fs.writeFile(qaRolePath, DEFAULT_QA_INSTRUCTIONS, "utf-8");
filesWritten.push("roles/default/qa.md");
}
// memory/projects.json
const memoryDir = path.join(workspacePath, "memory");
await fs.mkdir(memoryDir, { recursive: true });
const projectsJsonPath = path.join(memoryDir, "projects.json");
if (!await fileExists(projectsJsonPath)) {
await fs.writeFile(projectsJsonPath, JSON.stringify({ projects: {} }, null, 2) + "\n", "utf-8");
filesWritten.push("memory/projects.json");
}
return filesWritten;
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
async function backupAndWrite(filePath: string, content: string): Promise<void> {
try {
await fs.access(filePath);
await fs.copyFile(filePath, filePath + ".bak");
} catch {
await fs.mkdir(path.dirname(filePath), { recursive: true });
}
await fs.writeFile(filePath, content, "utf-8");
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}