clawchef 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +405 -0
- package/dist/api.d.ts +14 -0
- package/dist/api.js +49 -0
- package/dist/assertions.d.ts +2 -0
- package/dist/assertions.js +32 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +115 -0
- package/dist/env.d.ts +1 -0
- package/dist/env.js +14 -0
- package/dist/errors.d.ts +3 -0
- package/dist/errors.js +6 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +18 -0
- package/dist/logger.d.ts +7 -0
- package/dist/logger.js +17 -0
- package/dist/openclaw/command-provider.d.ts +15 -0
- package/dist/openclaw/command-provider.js +489 -0
- package/dist/openclaw/factory.d.ts +3 -0
- package/dist/openclaw/factory.js +13 -0
- package/dist/openclaw/mock-provider.d.ts +15 -0
- package/dist/openclaw/mock-provider.js +65 -0
- package/dist/openclaw/provider.d.ts +20 -0
- package/dist/openclaw/provider.js +1 -0
- package/dist/openclaw/remote-provider.d.ts +19 -0
- package/dist/openclaw/remote-provider.js +158 -0
- package/dist/orchestrator.d.ts +4 -0
- package/dist/orchestrator.js +243 -0
- package/dist/recipe.d.ts +20 -0
- package/dist/recipe.js +522 -0
- package/dist/schema.d.ts +626 -0
- package/dist/schema.js +143 -0
- package/dist/template.d.ts +2 -0
- package/dist/template.js +30 -0
- package/dist/types.d.ts +136 -0
- package/dist/types.js +1 -0
- package/package.json +41 -0
- package/recipes/content-from-sample.yaml +20 -0
- package/recipes/openclaw-from-zero.yaml +45 -0
- package/recipes/openclaw-local.yaml +65 -0
- package/recipes/openclaw-remote-http.yaml +38 -0
- package/recipes/openclaw-telegram-mock.yaml +22 -0
- package/recipes/openclaw-telegram.yaml +19 -0
- package/recipes/sample.yaml +49 -0
- package/recipes/snippets/readme-template.md +3 -0
- package/src/api.ts +65 -0
- package/src/assertions.ts +37 -0
- package/src/cli.ts +123 -0
- package/src/env.ts +16 -0
- package/src/errors.ts +6 -0
- package/src/index.ts +20 -0
- package/src/logger.ts +17 -0
- package/src/openclaw/command-provider.ts +594 -0
- package/src/openclaw/factory.ts +16 -0
- package/src/openclaw/mock-provider.ts +104 -0
- package/src/openclaw/provider.ts +44 -0
- package/src/openclaw/remote-provider.ts +264 -0
- package/src/orchestrator.ts +271 -0
- package/src/recipe.ts +621 -0
- package/src/schema.ts +157 -0
- package/src/template.ts +41 -0
- package/src/types.ts +150 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { AgentDef, ChannelDef, ConversationDef, OpenClawSection, WorkspaceDef } from "../types.js";
|
|
2
|
+
|
|
3
|
+
export type ResolvedWorkspaceDef = WorkspaceDef & { path: string };
|
|
4
|
+
|
|
5
|
+
export interface EnsureVersionResult {
|
|
6
|
+
installedThisRun: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface OpenClawProvider {
|
|
10
|
+
ensureVersion(config: OpenClawSection, dryRun: boolean, silent: boolean): Promise<EnsureVersionResult>;
|
|
11
|
+
factoryReset(config: OpenClawSection, dryRun: boolean): Promise<void>;
|
|
12
|
+
startGateway(config: OpenClawSection, dryRun: boolean): Promise<void>;
|
|
13
|
+
createWorkspace(config: OpenClawSection, workspace: ResolvedWorkspaceDef, dryRun: boolean): Promise<void>;
|
|
14
|
+
configureChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
|
|
15
|
+
loginChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
|
|
16
|
+
materializeFile?(
|
|
17
|
+
config: OpenClawSection,
|
|
18
|
+
workspace: string,
|
|
19
|
+
filePath: string,
|
|
20
|
+
content: string,
|
|
21
|
+
overwrite: boolean | undefined,
|
|
22
|
+
dryRun: boolean,
|
|
23
|
+
): Promise<void>;
|
|
24
|
+
createAgent(
|
|
25
|
+
config: OpenClawSection,
|
|
26
|
+
agent: AgentDef,
|
|
27
|
+
workspacePath: string,
|
|
28
|
+
dryRun: boolean,
|
|
29
|
+
): Promise<void>;
|
|
30
|
+
installSkill(
|
|
31
|
+
config: OpenClawSection,
|
|
32
|
+
workspace: string,
|
|
33
|
+
agent: string,
|
|
34
|
+
skill: string,
|
|
35
|
+
dryRun: boolean,
|
|
36
|
+
): Promise<void>;
|
|
37
|
+
sendMessage(
|
|
38
|
+
config: OpenClawSection,
|
|
39
|
+
conversation: ConversationDef,
|
|
40
|
+
content: string,
|
|
41
|
+
dryRun: boolean,
|
|
42
|
+
): Promise<void>;
|
|
43
|
+
runAgent(config: OpenClawSection, conversation: ConversationDef, dryRun: boolean): Promise<string>;
|
|
44
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { ClawChefError } from "../errors.js";
|
|
2
|
+
import type {
|
|
3
|
+
AgentDef,
|
|
4
|
+
ChannelDef,
|
|
5
|
+
ConversationDef,
|
|
6
|
+
OpenClawRemoteConfig,
|
|
7
|
+
OpenClawSection,
|
|
8
|
+
} from "../types.js";
|
|
9
|
+
import type { EnsureVersionResult, OpenClawProvider, ResolvedWorkspaceDef } from "./provider.js";
|
|
10
|
+
|
|
11
|
+
interface StagedMessage {
|
|
12
|
+
content: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface RemoteOperationRequest {
|
|
16
|
+
operation: string;
|
|
17
|
+
recipe_version: string;
|
|
18
|
+
payload?: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface RemoteOperationResponse {
|
|
22
|
+
ok?: boolean;
|
|
23
|
+
message?: string;
|
|
24
|
+
output?: string;
|
|
25
|
+
installed_this_run?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
29
|
+
const DEFAULT_OPERATION_PATH = "/v1/clawchef/operation";
|
|
30
|
+
|
|
31
|
+
function parseResponseBody(raw: string): RemoteOperationResponse {
|
|
32
|
+
if (!raw.trim()) {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(raw) as RemoteOperationResponse;
|
|
37
|
+
} catch {
|
|
38
|
+
return { message: raw.trim() };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildHeaders(remote: OpenClawRemoteConfig): Record<string, string> {
|
|
43
|
+
const headers: Record<string, string> = {
|
|
44
|
+
"content-type": "application/json",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
if (remote.api_key?.trim()) {
|
|
48
|
+
const headerName = remote.api_header?.trim() || "Authorization";
|
|
49
|
+
const scheme = remote.api_scheme === undefined ? "Bearer" : remote.api_scheme;
|
|
50
|
+
headers[headerName] = scheme ? `${scheme} ${remote.api_key}` : remote.api_key;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return headers;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function operationUrl(remote: OpenClawRemoteConfig): string {
|
|
57
|
+
const path = remote.operation_path?.trim() || DEFAULT_OPERATION_PATH;
|
|
58
|
+
return new URL(path, remote.base_url).toString();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function assertRemoteConfig(remote: Partial<OpenClawRemoteConfig>): OpenClawRemoteConfig {
|
|
62
|
+
const baseUrl = remote.base_url?.trim();
|
|
63
|
+
if (!baseUrl) {
|
|
64
|
+
throw new ClawChefError("--provider remote requires --remote-base-url (or CLAWCHEF_REMOTE_BASE_URL)");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
new URL(baseUrl);
|
|
69
|
+
} catch {
|
|
70
|
+
throw new ClawChefError(`Remote base URL is invalid: ${baseUrl}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
base_url: baseUrl,
|
|
75
|
+
api_key: remote.api_key,
|
|
76
|
+
api_header: remote.api_header,
|
|
77
|
+
api_scheme: remote.api_scheme,
|
|
78
|
+
timeout_ms: remote.timeout_ms,
|
|
79
|
+
operation_path: remote.operation_path,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class RemoteOpenClawProvider implements OpenClawProvider {
|
|
84
|
+
private readonly stagedMessages = new Map<string, StagedMessage[]>();
|
|
85
|
+
private readonly remoteConfig: Partial<OpenClawRemoteConfig>;
|
|
86
|
+
|
|
87
|
+
constructor(remoteConfig: Partial<OpenClawRemoteConfig>) {
|
|
88
|
+
this.remoteConfig = remoteConfig;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private async perform(
|
|
92
|
+
config: OpenClawSection,
|
|
93
|
+
operation: string,
|
|
94
|
+
payload: Record<string, unknown> | undefined,
|
|
95
|
+
dryRun: boolean,
|
|
96
|
+
): Promise<RemoteOperationResponse> {
|
|
97
|
+
if (dryRun) {
|
|
98
|
+
return { ok: true };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const remote = assertRemoteConfig(this.remoteConfig);
|
|
102
|
+
const timeoutMs = remote.timeout_ms ?? DEFAULT_TIMEOUT_MS;
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
105
|
+
|
|
106
|
+
const requestBody: RemoteOperationRequest = {
|
|
107
|
+
operation,
|
|
108
|
+
recipe_version: config.version,
|
|
109
|
+
payload,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const response = await fetch(operationUrl(remote), {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: buildHeaders(remote),
|
|
116
|
+
body: JSON.stringify(requestBody),
|
|
117
|
+
signal: controller.signal,
|
|
118
|
+
});
|
|
119
|
+
const raw = await response.text();
|
|
120
|
+
const parsed = parseResponseBody(raw);
|
|
121
|
+
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
throw new ClawChefError(
|
|
124
|
+
`Remote operation failed (${response.status}) for ${operation}: ${parsed.message ?? response.statusText}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (parsed.ok === false) {
|
|
129
|
+
throw new ClawChefError(`Remote operation failed for ${operation}: ${parsed.message ?? "unknown error"}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return parsed;
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (err instanceof ClawChefError) {
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
138
|
+
throw new ClawChefError(`Remote operation failed for ${operation}: ${message}`);
|
|
139
|
+
} finally {
|
|
140
|
+
clearTimeout(timeout);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async ensureVersion(config: OpenClawSection, dryRun: boolean, _silent: boolean): Promise<EnsureVersionResult> {
|
|
145
|
+
const result = await this.perform(
|
|
146
|
+
config,
|
|
147
|
+
"ensure_version",
|
|
148
|
+
{
|
|
149
|
+
install: config.install,
|
|
150
|
+
},
|
|
151
|
+
dryRun,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
installedThisRun: Boolean(result.installed_this_run),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async factoryReset(config: OpenClawSection, dryRun: boolean): Promise<void> {
|
|
160
|
+
await this.perform(config, "factory_reset", undefined, dryRun);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async startGateway(config: OpenClawSection, dryRun: boolean): Promise<void> {
|
|
164
|
+
await this.perform(config, "start_gateway", undefined, dryRun);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async createWorkspace(config: OpenClawSection, workspace: ResolvedWorkspaceDef, dryRun: boolean): Promise<void> {
|
|
168
|
+
await this.perform(config, "create_workspace", { workspace }, dryRun);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async configureChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void> {
|
|
172
|
+
await this.perform(config, "configure_channel", { channel }, dryRun);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async loginChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void> {
|
|
176
|
+
await this.perform(config, "login_channel", { channel }, dryRun);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async materializeFile(
|
|
180
|
+
config: OpenClawSection,
|
|
181
|
+
workspace: string,
|
|
182
|
+
filePath: string,
|
|
183
|
+
content: string,
|
|
184
|
+
overwrite: boolean | undefined,
|
|
185
|
+
dryRun: boolean,
|
|
186
|
+
): Promise<void> {
|
|
187
|
+
await this.perform(
|
|
188
|
+
config,
|
|
189
|
+
"materialize_file",
|
|
190
|
+
{
|
|
191
|
+
workspace,
|
|
192
|
+
path: filePath,
|
|
193
|
+
content,
|
|
194
|
+
overwrite,
|
|
195
|
+
},
|
|
196
|
+
dryRun,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async createAgent(
|
|
201
|
+
config: OpenClawSection,
|
|
202
|
+
agent: AgentDef,
|
|
203
|
+
workspacePath: string,
|
|
204
|
+
dryRun: boolean,
|
|
205
|
+
): Promise<void> {
|
|
206
|
+
await this.perform(
|
|
207
|
+
config,
|
|
208
|
+
"create_agent",
|
|
209
|
+
{
|
|
210
|
+
agent,
|
|
211
|
+
workspace_path: workspacePath,
|
|
212
|
+
},
|
|
213
|
+
dryRun,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async installSkill(
|
|
218
|
+
config: OpenClawSection,
|
|
219
|
+
workspace: string,
|
|
220
|
+
agent: string,
|
|
221
|
+
skill: string,
|
|
222
|
+
dryRun: boolean,
|
|
223
|
+
): Promise<void> {
|
|
224
|
+
await this.perform(
|
|
225
|
+
config,
|
|
226
|
+
"install_skill",
|
|
227
|
+
{
|
|
228
|
+
workspace,
|
|
229
|
+
agent,
|
|
230
|
+
skill,
|
|
231
|
+
},
|
|
232
|
+
dryRun,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async sendMessage(
|
|
237
|
+
_config: OpenClawSection,
|
|
238
|
+
conversation: ConversationDef,
|
|
239
|
+
content: string,
|
|
240
|
+
_dryRun: boolean,
|
|
241
|
+
): Promise<void> {
|
|
242
|
+
const key = `${conversation.workspace}::${conversation.agent}`;
|
|
243
|
+
const staged = this.stagedMessages.get(key) ?? [];
|
|
244
|
+
staged.push({ content });
|
|
245
|
+
this.stagedMessages.set(key, staged);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async runAgent(config: OpenClawSection, conversation: ConversationDef, dryRun: boolean): Promise<string> {
|
|
249
|
+
const key = `${conversation.workspace}::${conversation.agent}`;
|
|
250
|
+
const staged = this.stagedMessages.get(key) ?? [];
|
|
251
|
+
const prompt = staged.map((m) => `user: ${m.content}`).join("\n");
|
|
252
|
+
const result = await this.perform(
|
|
253
|
+
config,
|
|
254
|
+
"run_agent",
|
|
255
|
+
{
|
|
256
|
+
workspace: conversation.workspace,
|
|
257
|
+
agent: conversation.agent,
|
|
258
|
+
prompt,
|
|
259
|
+
},
|
|
260
|
+
dryRun,
|
|
261
|
+
);
|
|
262
|
+
return result.output ?? result.message ?? "";
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { mkdir, access, copyFile, writeFile, readFile } from "node:fs/promises";
|
|
4
|
+
import { constants } from "node:fs";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
7
|
+
import { validateReply } from "./assertions.js";
|
|
8
|
+
import { ClawChefError } from "./errors.js";
|
|
9
|
+
import { Logger } from "./logger.js";
|
|
10
|
+
import { createProvider } from "./openclaw/factory.js";
|
|
11
|
+
import type { Recipe, RunOptions } from "./types.js";
|
|
12
|
+
import type { RecipeOrigin } from "./recipe.js";
|
|
13
|
+
|
|
14
|
+
async function exists(filePath: string): Promise<boolean> {
|
|
15
|
+
try {
|
|
16
|
+
await access(filePath, constants.F_OK);
|
|
17
|
+
return true;
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function truncateForLog(text: string, maxLength = 500): string {
|
|
24
|
+
if (text.length <= maxLength) {
|
|
25
|
+
return text;
|
|
26
|
+
}
|
|
27
|
+
return `${text.slice(0, maxLength)}... [truncated ${text.length - maxLength} chars]`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveWorkspacePath(recipeOrigin: RecipeOrigin, name: string, configuredPath?: string): string {
|
|
31
|
+
if (configuredPath?.trim()) {
|
|
32
|
+
if (path.isAbsolute(configuredPath)) {
|
|
33
|
+
return configuredPath;
|
|
34
|
+
}
|
|
35
|
+
if (recipeOrigin.kind === "local") {
|
|
36
|
+
return path.resolve(recipeOrigin.recipeDir, configuredPath);
|
|
37
|
+
}
|
|
38
|
+
return path.resolve(configuredPath);
|
|
39
|
+
}
|
|
40
|
+
return path.join(homedir(), ".openclaw", "workspaces", name);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isHttpUrl(value: string): boolean {
|
|
44
|
+
try {
|
|
45
|
+
const url = new URL(value);
|
|
46
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resolveFileRef(recipeOrigin: RecipeOrigin, reference: string): { kind: "local" | "url"; value: string } {
|
|
53
|
+
if (isHttpUrl(reference)) {
|
|
54
|
+
return { kind: "url", value: reference };
|
|
55
|
+
}
|
|
56
|
+
if (path.isAbsolute(reference)) {
|
|
57
|
+
return { kind: "local", value: reference };
|
|
58
|
+
}
|
|
59
|
+
if (recipeOrigin.kind === "local") {
|
|
60
|
+
return { kind: "local", value: path.resolve(recipeOrigin.recipeDir, reference) };
|
|
61
|
+
}
|
|
62
|
+
return { kind: "url", value: new URL(reference, recipeOrigin.recipeUrl).toString() };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readTextFromRef(recipeOrigin: RecipeOrigin, reference: string): Promise<string> {
|
|
66
|
+
const resolved = resolveFileRef(recipeOrigin, reference);
|
|
67
|
+
if (resolved.kind === "local") {
|
|
68
|
+
return readFile(resolved.value, "utf8");
|
|
69
|
+
}
|
|
70
|
+
const response = await fetch(resolved.value);
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
throw new ClawChefError(`Failed to fetch file content from ${resolved.value}: HTTP ${response.status}`);
|
|
73
|
+
}
|
|
74
|
+
return response.text();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function readBinaryFromRef(recipeOrigin: RecipeOrigin, reference: string): Promise<Buffer> {
|
|
78
|
+
const resolved = resolveFileRef(recipeOrigin, reference);
|
|
79
|
+
if (resolved.kind === "local") {
|
|
80
|
+
return readFile(resolved.value);
|
|
81
|
+
}
|
|
82
|
+
const response = await fetch(resolved.value);
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
throw new ClawChefError(`Failed to fetch file source from ${resolved.value}: HTTP ${response.status}`);
|
|
85
|
+
}
|
|
86
|
+
const bytes = await response.arrayBuffer();
|
|
87
|
+
return Buffer.from(bytes);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function confirmFactoryReset(options: RunOptions): Promise<boolean> {
|
|
91
|
+
if (options.silent || options.dryRun) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
if (!input.isTTY) {
|
|
95
|
+
throw new ClawChefError("Reset confirmation requires an interactive terminal. Use --silent to skip prompt.");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const rl = createInterface({ input, output });
|
|
99
|
+
try {
|
|
100
|
+
const answer = await rl.question(
|
|
101
|
+
"This run will factory-reset existing OpenClaw state before execution. Continue? [y/N] ",
|
|
102
|
+
);
|
|
103
|
+
return ["y", "yes"].includes(answer.trim().toLowerCase());
|
|
104
|
+
} finally {
|
|
105
|
+
rl.close();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function runRecipe(
|
|
110
|
+
recipe: Recipe,
|
|
111
|
+
recipeOrigin: RecipeOrigin,
|
|
112
|
+
options: RunOptions,
|
|
113
|
+
logger: Logger,
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
const provider = createProvider(options);
|
|
116
|
+
const remoteMode = options.provider === "remote";
|
|
117
|
+
const workspacePaths = new Map<string, string>();
|
|
118
|
+
|
|
119
|
+
logger.info(`Running recipe: ${recipe.name}`);
|
|
120
|
+
const versionResult = await provider.ensureVersion(recipe.openclaw, options.dryRun, options.silent);
|
|
121
|
+
logger.info(`OpenClaw version ready: ${recipe.openclaw.version}`);
|
|
122
|
+
|
|
123
|
+
if (versionResult.installedThisRun) {
|
|
124
|
+
logger.info("OpenClaw was installed in this run; skipping factory reset");
|
|
125
|
+
} else {
|
|
126
|
+
const confirmed = await confirmFactoryReset(options);
|
|
127
|
+
if (!confirmed) {
|
|
128
|
+
throw new ClawChefError("Aborted by user before factory reset");
|
|
129
|
+
}
|
|
130
|
+
await provider.factoryReset(recipe.openclaw, options.dryRun);
|
|
131
|
+
logger.info("Factory reset completed");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const ws of recipe.workspaces ?? []) {
|
|
135
|
+
const absPath = resolveWorkspacePath(recipeOrigin, ws.name, ws.path);
|
|
136
|
+
workspacePaths.set(ws.name, absPath);
|
|
137
|
+
if (!options.dryRun && !remoteMode) {
|
|
138
|
+
await mkdir(absPath, { recursive: true });
|
|
139
|
+
}
|
|
140
|
+
await provider.createWorkspace(recipe.openclaw, { ...ws, path: absPath }, options.dryRun);
|
|
141
|
+
logger.info(`Workspace created: ${ws.name}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const agent of recipe.agents ?? []) {
|
|
145
|
+
const workspacePath = workspacePaths.get(agent.workspace);
|
|
146
|
+
if (!workspacePath) {
|
|
147
|
+
throw new ClawChefError(`Agent references missing workspace: ${agent.workspace}`);
|
|
148
|
+
}
|
|
149
|
+
await provider.createAgent(recipe.openclaw, agent, workspacePath, options.dryRun);
|
|
150
|
+
logger.info(`Agent created: ${agent.workspace}/${agent.name}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const channel of recipe.channels ?? []) {
|
|
154
|
+
await provider.configureChannel(recipe.openclaw, channel, options.dryRun);
|
|
155
|
+
logger.info(`Channel configured: ${channel.channel}${channel.account ? `/${channel.account}` : ""}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const file of recipe.files ?? []) {
|
|
159
|
+
const wsPath = workspacePaths.get(file.workspace);
|
|
160
|
+
if (!wsPath) {
|
|
161
|
+
throw new ClawChefError(`File target workspace does not exist: ${file.workspace}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (provider.materializeFile) {
|
|
165
|
+
let content = file.content;
|
|
166
|
+
if (content === undefined && file.content_from) {
|
|
167
|
+
if (!options.dryRun) {
|
|
168
|
+
content = await readTextFromRef(recipeOrigin, file.content_from);
|
|
169
|
+
} else {
|
|
170
|
+
const resolved = resolveFileRef(recipeOrigin, file.content_from);
|
|
171
|
+
content = `__dry_run_content_from__:${resolved.value}`;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (content === undefined && file.source) {
|
|
175
|
+
if (!options.dryRun) {
|
|
176
|
+
content = await readTextFromRef(recipeOrigin, file.source);
|
|
177
|
+
} else {
|
|
178
|
+
const resolved = resolveFileRef(recipeOrigin, file.source);
|
|
179
|
+
content = `__dry_run_source__:${resolved.value}`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (content === undefined) {
|
|
183
|
+
throw new ClawChefError(`File ${file.path} requires content, content_from, or source`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
await provider.materializeFile(recipe.openclaw, file.workspace, file.path, content, file.overwrite, options.dryRun);
|
|
187
|
+
logger.info(`File materialized: ${file.workspace}/${file.path}`);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const target = path.resolve(wsPath, file.path);
|
|
192
|
+
const targetDir = path.dirname(target);
|
|
193
|
+
|
|
194
|
+
if (!options.dryRun) {
|
|
195
|
+
await mkdir(targetDir, { recursive: true });
|
|
196
|
+
const alreadyExists = await exists(target);
|
|
197
|
+
if (alreadyExists && file.overwrite === false) {
|
|
198
|
+
logger.warn(`Skipping existing file: ${target}`);
|
|
199
|
+
} else if (file.content !== undefined) {
|
|
200
|
+
await writeFile(target, file.content, "utf8");
|
|
201
|
+
} else if (file.content_from) {
|
|
202
|
+
const content = await readTextFromRef(recipeOrigin, file.content_from);
|
|
203
|
+
await writeFile(target, content, "utf8");
|
|
204
|
+
} else if (file.source) {
|
|
205
|
+
const resolved = resolveFileRef(recipeOrigin, file.source);
|
|
206
|
+
if (resolved.kind === "local") {
|
|
207
|
+
await copyFile(resolved.value, target);
|
|
208
|
+
} else {
|
|
209
|
+
const content = await readBinaryFromRef(recipeOrigin, file.source);
|
|
210
|
+
await writeFile(target, content);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
logger.info(`File materialized: ${file.workspace}/${file.path}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const agent of recipe.agents ?? []) {
|
|
218
|
+
for (const skill of agent.skills ?? []) {
|
|
219
|
+
await provider.installSkill(recipe.openclaw, agent.workspace, agent.name, skill, options.dryRun);
|
|
220
|
+
logger.info(`Skill installed: ${agent.workspace}/${agent.name} -> ${skill}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (const conv of recipe.conversations ?? []) {
|
|
225
|
+
for (const msg of conv.messages) {
|
|
226
|
+
await provider.sendMessage(recipe.openclaw, conv, msg.content, options.dryRun);
|
|
227
|
+
|
|
228
|
+
const shouldRun = conv.run ?? Boolean(msg.expect);
|
|
229
|
+
if (shouldRun) {
|
|
230
|
+
if (options.dryRun) {
|
|
231
|
+
logger.info(`dry-run: skipping execution and output assertions: ${conv.workspace}/${conv.agent}`);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const reply = await provider.runAgent(recipe.openclaw, conv, options.dryRun);
|
|
235
|
+
if (msg.expect) {
|
|
236
|
+
try {
|
|
237
|
+
validateReply(reply, msg.expect);
|
|
238
|
+
logger.info(`Output assertions passed: ${conv.workspace}/${conv.agent}`);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
logger.warn(
|
|
241
|
+
`Assertion failed reply (truncated): ${truncateForLog(reply)}`,
|
|
242
|
+
);
|
|
243
|
+
throw err;
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
logger.info(`Agent executed: ${conv.workspace}/${conv.agent}`);
|
|
247
|
+
}
|
|
248
|
+
logger.debug(`Agent output: ${reply}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
logger.info(`Preset messages sent: ${conv.workspace}/${conv.agent}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
await provider.startGateway(recipe.openclaw, options.dryRun);
|
|
255
|
+
logger.info("Gateway started");
|
|
256
|
+
|
|
257
|
+
for (const channel of recipe.channels ?? []) {
|
|
258
|
+
if (!channel.login) {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (!options.dryRun && !input.isTTY) {
|
|
262
|
+
throw new ClawChefError(
|
|
263
|
+
`Channel login for ${channel.channel} requires an interactive terminal session`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
await provider.loginChannel(recipe.openclaw, channel, options.dryRun);
|
|
267
|
+
logger.info(`Channel login completed: ${channel.channel}${channel.account ? `/${channel.account}` : ""}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
logger.info("Recipe execution completed");
|
|
271
|
+
}
|