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:
87
lib/setup/agent.ts
Normal file
87
lib/setup/agent.ts
Normal 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
88
lib/setup/config.ts
Normal 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
120
lib/setup/index.ts
Normal 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
79
lib/setup/workspace.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user