clawchef 0.1.6 → 0.1.8
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/AGENTS.md +159 -0
- package/README.md +32 -14
- package/dist/api.d.ts +3 -1
- package/dist/api.js +10 -1
- package/dist/cli.js +34 -3
- package/dist/openclaw/command-provider.d.ts +2 -1
- package/dist/openclaw/command-provider.js +109 -23
- package/dist/openclaw/mock-provider.d.ts +2 -1
- package/dist/openclaw/mock-provider.js +4 -1
- package/dist/openclaw/provider.d.ts +2 -1
- package/dist/openclaw/remote-provider.d.ts +2 -1
- package/dist/openclaw/remote-provider.js +8 -1
- package/dist/orchestrator.js +90 -56
- package/dist/recipe.js +71 -24
- package/dist/schema.d.ts +84 -107
- package/dist/schema.js +17 -21
- package/dist/types.d.ts +8 -11
- package/package.json +1 -1
- package/recipes/content-from-sample.yaml +13 -9
- package/recipes/openclaw-from-zero.yaml +2 -2
- package/recipes/openclaw-local.yaml +8 -10
- package/recipes/openclaw-remote-http.yaml +6 -8
- package/recipes/sample.yaml +6 -8
- package/recipes/snippets/readme-template.md +3 -1
- package/src/api.ts +13 -2
- package/src/cli.ts +36 -4
- package/src/openclaw/command-provider.ts +134 -24
- package/src/openclaw/mock-provider.ts +10 -1
- package/src/openclaw/provider.ts +2 -1
- package/src/openclaw/remote-provider.ts +14 -1
- package/src/orchestrator.ts +93 -55
- package/src/recipe.ts +82 -24
- package/src/schema.ts +19 -22
- package/src/types.ts +8 -11
package/recipes/sample.yaml
CHANGED
|
@@ -15,6 +15,12 @@ openclaw:
|
|
|
15
15
|
|
|
16
16
|
workspaces:
|
|
17
17
|
- name: "${project_name}"
|
|
18
|
+
files:
|
|
19
|
+
- path: "README.md"
|
|
20
|
+
overwrite: true
|
|
21
|
+
content: |
|
|
22
|
+
# ${project_name}
|
|
23
|
+
Generated by clawchef.
|
|
18
24
|
|
|
19
25
|
channels:
|
|
20
26
|
- channel: "telegram"
|
|
@@ -27,14 +33,6 @@ agents:
|
|
|
27
33
|
model: "gpt-5.3-codex"
|
|
28
34
|
skills: ["repo-explore", "code-review"]
|
|
29
35
|
|
|
30
|
-
files:
|
|
31
|
-
- workspace: "${project_name}"
|
|
32
|
-
path: "README.md"
|
|
33
|
-
overwrite: true
|
|
34
|
-
content: |
|
|
35
|
-
# ${project_name}
|
|
36
|
-
Generated by clawchef.
|
|
37
|
-
|
|
38
36
|
conversations:
|
|
39
37
|
- workspace: "${project_name}"
|
|
40
38
|
agent: "planner"
|
package/src/api.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { runRecipe } from "./orchestrator.js";
|
|
|
6
6
|
import { loadRecipe, loadRecipeText } from "./recipe.js";
|
|
7
7
|
import { scaffoldProject } from "./scaffold.js";
|
|
8
8
|
import { recipeSchema } from "./schema.js";
|
|
9
|
-
import type { OpenClawProvider, OpenClawRemoteConfig, RunOptions } from "./types.js";
|
|
9
|
+
import type { OpenClawProvider, OpenClawRemoteConfig, RunOptions, RunScope } from "./types.js";
|
|
10
10
|
import type { ScaffoldOptions, ScaffoldResult } from "./scaffold.js";
|
|
11
11
|
|
|
12
12
|
export interface CookOptions {
|
|
@@ -16,6 +16,8 @@ export interface CookOptions {
|
|
|
16
16
|
allowMissing?: boolean;
|
|
17
17
|
verbose?: boolean;
|
|
18
18
|
silent?: boolean;
|
|
19
|
+
scope?: RunScope;
|
|
20
|
+
workspaceName?: string;
|
|
19
21
|
provider?: OpenClawProvider;
|
|
20
22
|
remote?: Partial<OpenClawRemoteConfig>;
|
|
21
23
|
envFile?: string;
|
|
@@ -24,14 +26,23 @@ export interface CookOptions {
|
|
|
24
26
|
|
|
25
27
|
function normalizeCookOptions(options: CookOptions): RunOptions {
|
|
26
28
|
const plugins = Array.from(new Set((options.plugins ?? []).map((value) => value.trim()).filter((value) => value.length > 0)));
|
|
29
|
+
const scope = options.scope ?? "full";
|
|
30
|
+
const workspaceName = options.workspaceName?.trim() || undefined;
|
|
31
|
+
if (scope === "workspace" && !workspaceName) {
|
|
32
|
+
throw new ClawChefError("scope=workspace requires workspaceName");
|
|
33
|
+
}
|
|
34
|
+
if (scope !== "workspace" && workspaceName) {
|
|
35
|
+
throw new ClawChefError("workspaceName is only allowed when scope=workspace");
|
|
36
|
+
}
|
|
27
37
|
return {
|
|
28
38
|
vars: options.vars ?? {},
|
|
29
39
|
plugins,
|
|
40
|
+
scope,
|
|
41
|
+
workspaceName,
|
|
30
42
|
dryRun: Boolean(options.dryRun),
|
|
31
43
|
allowMissing: Boolean(options.allowMissing),
|
|
32
44
|
verbose: Boolean(options.verbose),
|
|
33
45
|
silent: options.silent ?? true,
|
|
34
|
-
keepOpenClawState: false,
|
|
35
46
|
provider: options.provider ?? "command",
|
|
36
47
|
remote: options.remote ?? {},
|
|
37
48
|
};
|
package/src/cli.ts
CHANGED
|
@@ -6,12 +6,27 @@ import { runRecipe } from "./orchestrator.js";
|
|
|
6
6
|
import { loadRecipe, loadRecipeText } from "./recipe.js";
|
|
7
7
|
import { recipeSchema } from "./schema.js";
|
|
8
8
|
import { scaffoldProject } from "./scaffold.js";
|
|
9
|
-
import type { RunOptions } from "./types.js";
|
|
9
|
+
import type { RunOptions, RunScope } from "./types.js";
|
|
10
10
|
import YAML from "js-yaml";
|
|
11
11
|
import path from "node:path";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
12
13
|
import { createInterface } from "node:readline/promises";
|
|
13
14
|
import { stdin as input, stdout as output } from "node:process";
|
|
14
15
|
|
|
16
|
+
function readPackageVersion(): string {
|
|
17
|
+
try {
|
|
18
|
+
const pkgPath = new URL("../package.json", import.meta.url);
|
|
19
|
+
const content = readFileSync(pkgPath, "utf8");
|
|
20
|
+
const parsed = JSON.parse(content) as { version?: string };
|
|
21
|
+
if (parsed.version?.trim()) {
|
|
22
|
+
return parsed.version;
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
// ignore and use fallback
|
|
26
|
+
}
|
|
27
|
+
return "0.0.0";
|
|
28
|
+
}
|
|
29
|
+
|
|
15
30
|
function parseVarFlags(values: string[]): Record<string, string> {
|
|
16
31
|
const out: Record<string, string> = {};
|
|
17
32
|
for (const item of values) {
|
|
@@ -46,6 +61,13 @@ function parseProvider(value: string): "command" | "mock" | "remote" {
|
|
|
46
61
|
throw new ClawChefError(`Invalid --provider value: ${value}. Expected command, remote, or mock`);
|
|
47
62
|
}
|
|
48
63
|
|
|
64
|
+
function parseScope(value: string): RunScope {
|
|
65
|
+
if (value === "full" || value === "files" || value === "workspace") {
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
throw new ClawChefError(`Invalid --scope value: ${value}. Expected full, files, or workspace`);
|
|
69
|
+
}
|
|
70
|
+
|
|
49
71
|
function parseOptionalInt(value: string | undefined, fieldName: string): number | undefined {
|
|
50
72
|
if (value === undefined) {
|
|
51
73
|
return undefined;
|
|
@@ -78,7 +100,7 @@ export function buildCli(): Command {
|
|
|
78
100
|
program
|
|
79
101
|
.name("clawchef")
|
|
80
102
|
.description("Run OpenClaw environment recipes")
|
|
81
|
-
.version(
|
|
103
|
+
.version(readPackageVersion());
|
|
82
104
|
|
|
83
105
|
program
|
|
84
106
|
.command("cook")
|
|
@@ -88,7 +110,8 @@ export function buildCli(): Command {
|
|
|
88
110
|
.option("--allow-missing", "Allow unresolved template variables", false)
|
|
89
111
|
.option("--verbose", "Verbose logging", false)
|
|
90
112
|
.option("-s, --silent", "Skip reset confirmation prompt", false)
|
|
91
|
-
.option("--
|
|
113
|
+
.option("--scope <scope>", "Run scope: full | files | workspace", "full")
|
|
114
|
+
.option("--workspace <name>", "Workspace name (required when --scope workspace)")
|
|
92
115
|
.option("--dotenv-ref <path-or-url>", "Load env vars from local file or HTTP URL")
|
|
93
116
|
.option("--provider <provider>", "Execution provider: command | remote | mock")
|
|
94
117
|
.option("--plugin <npm-spec>", "Preinstall plugin package (repeatable)", (v, p: string[]) => p.concat([v]), [])
|
|
@@ -106,14 +129,23 @@ export function buildCli(): Command {
|
|
|
106
129
|
}
|
|
107
130
|
|
|
108
131
|
const provider = parseProvider(opts.provider ?? readEnv("CLAWCHEF_PROVIDER") ?? "command");
|
|
132
|
+
const scope = parseScope(String(opts.scope ?? "full"));
|
|
133
|
+
const workspaceName = opts.workspace?.trim() ? String(opts.workspace).trim() : undefined;
|
|
134
|
+
if (scope === "workspace" && !workspaceName) {
|
|
135
|
+
throw new ClawChefError("--scope workspace requires --workspace <name>");
|
|
136
|
+
}
|
|
137
|
+
if (scope !== "workspace" && workspaceName) {
|
|
138
|
+
throw new ClawChefError("--workspace is only allowed when --scope workspace");
|
|
139
|
+
}
|
|
109
140
|
const options: RunOptions = {
|
|
110
141
|
vars: parseVarFlags(opts.var),
|
|
111
142
|
plugins: parsePluginFlags(opts.plugin),
|
|
143
|
+
scope,
|
|
144
|
+
workspaceName,
|
|
112
145
|
dryRun: Boolean(opts.dryRun),
|
|
113
146
|
allowMissing: Boolean(opts.allowMissing),
|
|
114
147
|
verbose: Boolean(opts.verbose),
|
|
115
148
|
silent: Boolean(opts.silent),
|
|
116
|
-
keepOpenClawState: Boolean(opts.keepOpenclawState),
|
|
117
149
|
provider,
|
|
118
150
|
remote: {
|
|
119
151
|
base_url: opts.remoteBaseUrl ?? readEnv("CLAWCHEF_REMOTE_BASE_URL"),
|
|
@@ -17,6 +17,7 @@ const DEFAULT_COMMANDS = {
|
|
|
17
17
|
factory_reset: "${bin} reset --scope full --yes --non-interactive",
|
|
18
18
|
start_gateway: "${bin} gateway start",
|
|
19
19
|
enable_plugin: "",
|
|
20
|
+
bind_channel_agent: "",
|
|
20
21
|
login_channel: "${bin} channels login --channel ${channel_q}${account_arg}",
|
|
21
22
|
create_agent:
|
|
22
23
|
"${bin} agents add ${agent} --workspace ${workspace_path} --model ${model} --non-interactive --json",
|
|
@@ -31,31 +32,51 @@ interface StagedMessage {
|
|
|
31
32
|
content: string;
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
interface BindingItem {
|
|
36
|
+
agentId?: unknown;
|
|
37
|
+
match?: {
|
|
38
|
+
channel?: unknown;
|
|
39
|
+
accountId?: unknown;
|
|
40
|
+
peer?: unknown;
|
|
41
|
+
parentPeer?: unknown;
|
|
42
|
+
guildId?: unknown;
|
|
43
|
+
teamId?: unknown;
|
|
44
|
+
roles?: unknown;
|
|
45
|
+
};
|
|
46
|
+
[key: string]: unknown;
|
|
47
|
+
}
|
|
48
|
+
|
|
34
49
|
const SECRET_FLAG_RE =
|
|
35
50
|
/(--[A-Za-z0-9-]*(?:api-key|token|password|secret)[A-Za-z0-9-]*\s+)(?:'[^']*'|"[^"]*"|\S+)/g;
|
|
36
51
|
|
|
37
52
|
type BootstrapStringField =
|
|
38
|
-
| "openai_api_key"
|
|
39
|
-
| "anthropic_api_key"
|
|
40
|
-
| "openrouter_api_key"
|
|
41
|
-
| "xai_api_key"
|
|
42
|
-
| "gemini_api_key"
|
|
43
|
-
| "ai_gateway_api_key"
|
|
44
|
-
| "cloudflare_ai_gateway_api_key"
|
|
45
53
|
| "cloudflare_ai_gateway_account_id"
|
|
46
54
|
| "cloudflare_ai_gateway_gateway_id"
|
|
47
55
|
| "token"
|
|
48
56
|
| "token_provider"
|
|
49
57
|
| "token_profile_id";
|
|
50
58
|
|
|
59
|
+
const AUTH_CHOICE_TO_LLM_FLAG: Record<string, string> = {
|
|
60
|
+
"openai-api-key": "--openai-api-key",
|
|
61
|
+
"anthropic-api-key": "--anthropic-api-key",
|
|
62
|
+
"openrouter-api-key": "--openrouter-api-key",
|
|
63
|
+
"xai-api-key": "--xai-api-key",
|
|
64
|
+
"gemini-api-key": "--gemini-api-key",
|
|
65
|
+
"ai-gateway-api-key": "--ai-gateway-api-key",
|
|
66
|
+
"cloudflare-ai-gateway-api-key": "--cloudflare-ai-gateway-api-key",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const AUTH_CHOICE_TO_LLM_ENV: Record<string, string> = {
|
|
70
|
+
"openai-api-key": "OPENAI_API_KEY",
|
|
71
|
+
"anthropic-api-key": "ANTHROPIC_API_KEY",
|
|
72
|
+
"openrouter-api-key": "OPENROUTER_API_KEY",
|
|
73
|
+
"xai-api-key": "XAI_API_KEY",
|
|
74
|
+
"gemini-api-key": "GEMINI_API_KEY",
|
|
75
|
+
"ai-gateway-api-key": "AI_GATEWAY_API_KEY",
|
|
76
|
+
"cloudflare-ai-gateway-api-key": "CLOUDFLARE_AI_GATEWAY_API_KEY",
|
|
77
|
+
};
|
|
78
|
+
|
|
51
79
|
const BOOTSTRAP_STRING_FLAGS: Array<[BootstrapStringField, string]> = [
|
|
52
|
-
["openai_api_key", "--openai-api-key"],
|
|
53
|
-
["anthropic_api_key", "--anthropic-api-key"],
|
|
54
|
-
["openrouter_api_key", "--openrouter-api-key"],
|
|
55
|
-
["xai_api_key", "--xai-api-key"],
|
|
56
|
-
["gemini_api_key", "--gemini-api-key"],
|
|
57
|
-
["ai_gateway_api_key", "--ai-gateway-api-key"],
|
|
58
|
-
["cloudflare_ai_gateway_api_key", "--cloudflare-ai-gateway-api-key"],
|
|
59
80
|
["cloudflare_ai_gateway_account_id", "--cloudflare-ai-gateway-account-id"],
|
|
60
81
|
["cloudflare_ai_gateway_gateway_id", "--cloudflare-ai-gateway-gateway-id"],
|
|
61
82
|
["token", "--token"],
|
|
@@ -239,6 +260,13 @@ function buildBootstrapCommand(bin: string, bootstrap: OpenClawBootstrap | undef
|
|
|
239
260
|
flags.push("--no-install-daemon");
|
|
240
261
|
}
|
|
241
262
|
|
|
263
|
+
if (cfg.llm_api_key?.trim()) {
|
|
264
|
+
const llmFlag = AUTH_CHOICE_TO_LLM_FLAG[cfg.auth_choice ?? ""];
|
|
265
|
+
if (llmFlag) {
|
|
266
|
+
flags.push(`${llmFlag} ${shellQuote(cfg.llm_api_key)}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
242
270
|
for (const [field, flag] of BOOTSTRAP_STRING_FLAGS) {
|
|
243
271
|
const value = cfg[field];
|
|
244
272
|
if (value && value.trim()) {
|
|
@@ -257,19 +285,49 @@ function bootstrapRuntimeEnv(bootstrap: OpenClawBootstrap | undefined): Record<s
|
|
|
257
285
|
}
|
|
258
286
|
const env: Record<string, string> = {};
|
|
259
287
|
|
|
260
|
-
if (bootstrap.
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if (bootstrap.ai_gateway_api_key) env.AI_GATEWAY_API_KEY = bootstrap.ai_gateway_api_key;
|
|
266
|
-
if (bootstrap.cloudflare_ai_gateway_api_key) {
|
|
267
|
-
env.CLOUDFLARE_AI_GATEWAY_API_KEY = bootstrap.cloudflare_ai_gateway_api_key;
|
|
288
|
+
if (bootstrap.llm_api_key?.trim()) {
|
|
289
|
+
const envKey = AUTH_CHOICE_TO_LLM_ENV[bootstrap.auth_choice ?? ""];
|
|
290
|
+
if (envKey) {
|
|
291
|
+
env[envKey] = bootstrap.llm_api_key;
|
|
292
|
+
}
|
|
268
293
|
}
|
|
269
294
|
|
|
270
295
|
return env;
|
|
271
296
|
}
|
|
272
297
|
|
|
298
|
+
function isAccountLevelBinding(item: BindingItem, channel: string, account: string): boolean {
|
|
299
|
+
const match = item.match;
|
|
300
|
+
if (!match || typeof match !== "object") {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
if (match.channel !== channel || match.accountId !== account) {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
return (
|
|
307
|
+
match.peer === undefined
|
|
308
|
+
&& match.parentPeer === undefined
|
|
309
|
+
&& match.guildId === undefined
|
|
310
|
+
&& match.teamId === undefined
|
|
311
|
+
&& match.roles === undefined
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function parseBindingsJson(raw: string): BindingItem[] {
|
|
316
|
+
if (!raw.trim()) {
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
321
|
+
if (!Array.isArray(parsed)) {
|
|
322
|
+
throw new ClawChefError("openclaw config bindings is not an array");
|
|
323
|
+
}
|
|
324
|
+
return parsed as BindingItem[];
|
|
325
|
+
} catch (err) {
|
|
326
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
327
|
+
throw new ClawChefError(`Failed to parse openclaw bindings JSON: ${message}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
273
331
|
export class CommandOpenClawProvider implements OpenClawProvider {
|
|
274
332
|
private readonly stagedMessages = new Map<string, StagedMessage[]>();
|
|
275
333
|
|
|
@@ -277,7 +335,7 @@ export class CommandOpenClawProvider implements OpenClawProvider {
|
|
|
277
335
|
config: OpenClawSection,
|
|
278
336
|
dryRun: boolean,
|
|
279
337
|
silent: boolean,
|
|
280
|
-
|
|
338
|
+
preserveExistingState: boolean,
|
|
281
339
|
): Promise<EnsureVersionResult> {
|
|
282
340
|
const bin = config.bin ?? "openclaw";
|
|
283
341
|
const installPolicy = config.install ?? "auto";
|
|
@@ -343,7 +401,7 @@ export class CommandOpenClawProvider implements OpenClawProvider {
|
|
|
343
401
|
);
|
|
344
402
|
}
|
|
345
403
|
|
|
346
|
-
if (
|
|
404
|
+
if (preserveExistingState) {
|
|
347
405
|
return { installedThisRun: false };
|
|
348
406
|
}
|
|
349
407
|
|
|
@@ -492,6 +550,58 @@ export class CommandOpenClawProvider implements OpenClawProvider {
|
|
|
492
550
|
await runShell(cmd, dryRun);
|
|
493
551
|
}
|
|
494
552
|
|
|
553
|
+
async bindChannelAgent(config: OpenClawSection, channel: ChannelDef, agent: string, dryRun: boolean): Promise<void> {
|
|
554
|
+
const account = channel.account?.trim();
|
|
555
|
+
if (!account) {
|
|
556
|
+
throw new ClawChefError(`Channel ${channel.channel} requires account for agent binding`);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const bin = config.bin ?? "openclaw";
|
|
560
|
+
const customTemplate = config.commands?.bind_channel_agent;
|
|
561
|
+
if (customTemplate?.trim()) {
|
|
562
|
+
const customCmd = fillTemplate(customTemplate, {
|
|
563
|
+
bin,
|
|
564
|
+
version: config.version,
|
|
565
|
+
channel: channel.channel,
|
|
566
|
+
channel_q: shellQuote(channel.channel),
|
|
567
|
+
account,
|
|
568
|
+
account_q: shellQuote(account),
|
|
569
|
+
agent,
|
|
570
|
+
agent_q: shellQuote(agent),
|
|
571
|
+
});
|
|
572
|
+
if (customCmd.trim()) {
|
|
573
|
+
await runShell(customCmd, dryRun);
|
|
574
|
+
}
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (dryRun) {
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const getCmd = `${bin} config get bindings --json 2>/dev/null || printf '[]'`;
|
|
583
|
+
const rawBindings = await runShell(getCmd, false);
|
|
584
|
+
const bindings = parseBindingsJson(rawBindings);
|
|
585
|
+
const nextBinding: BindingItem = {
|
|
586
|
+
agentId: agent,
|
|
587
|
+
match: {
|
|
588
|
+
channel: channel.channel,
|
|
589
|
+
accountId: account,
|
|
590
|
+
},
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
const index = bindings.findIndex((item) => isAccountLevelBinding(item, channel.channel, account));
|
|
594
|
+
if (index >= 0) {
|
|
595
|
+
bindings[index] = nextBinding;
|
|
596
|
+
} else {
|
|
597
|
+
bindings.push(nextBinding);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const json = JSON.stringify(bindings);
|
|
601
|
+
const setCmd = `${bin} config set bindings ${shellQuote(json)} --json`;
|
|
602
|
+
await runShell(setCmd, false);
|
|
603
|
+
}
|
|
604
|
+
|
|
495
605
|
async loginChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void> {
|
|
496
606
|
if (!channel.login) {
|
|
497
607
|
return;
|
|
@@ -25,7 +25,7 @@ export class MockOpenClawProvider implements OpenClawProvider {
|
|
|
25
25
|
config: OpenClawSection,
|
|
26
26
|
_dryRun: boolean,
|
|
27
27
|
_silent: boolean,
|
|
28
|
-
|
|
28
|
+
_preserveExistingState: boolean,
|
|
29
29
|
): Promise<EnsureVersionResult> {
|
|
30
30
|
const policy = config.install ?? "auto";
|
|
31
31
|
const installed = this.state.installedVersions.has(config.version);
|
|
@@ -69,6 +69,15 @@ export class MockOpenClawProvider implements OpenClawProvider {
|
|
|
69
69
|
this.state.channels.add(`${channel.channel}::${channel.account ?? "default"}`);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
async bindChannelAgent(
|
|
73
|
+
_config: OpenClawSection,
|
|
74
|
+
_channel: ChannelDef,
|
|
75
|
+
_agent: string,
|
|
76
|
+
_dryRun: boolean,
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
72
81
|
async loginChannel(_config: OpenClawSection, _channel: ChannelDef, _dryRun: boolean): Promise<void> {
|
|
73
82
|
return;
|
|
74
83
|
}
|
package/src/openclaw/provider.ts
CHANGED
|
@@ -11,13 +11,14 @@ export interface OpenClawProvider {
|
|
|
11
11
|
config: OpenClawSection,
|
|
12
12
|
dryRun: boolean,
|
|
13
13
|
silent: boolean,
|
|
14
|
-
|
|
14
|
+
preserveExistingState: boolean,
|
|
15
15
|
): Promise<EnsureVersionResult>;
|
|
16
16
|
installPlugin(config: OpenClawSection, pluginSpec: string, dryRun: boolean): Promise<void>;
|
|
17
17
|
factoryReset(config: OpenClawSection, dryRun: boolean): Promise<void>;
|
|
18
18
|
startGateway(config: OpenClawSection, dryRun: boolean): Promise<void>;
|
|
19
19
|
createWorkspace(config: OpenClawSection, workspace: ResolvedWorkspaceDef, dryRun: boolean): Promise<void>;
|
|
20
20
|
configureChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
|
|
21
|
+
bindChannelAgent(config: OpenClawSection, channel: ChannelDef, agent: string, dryRun: boolean): Promise<void>;
|
|
21
22
|
loginChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
|
|
22
23
|
materializeFile?(
|
|
23
24
|
config: OpenClawSection,
|
|
@@ -145,7 +145,7 @@ export class RemoteOpenClawProvider implements OpenClawProvider {
|
|
|
145
145
|
config: OpenClawSection,
|
|
146
146
|
dryRun: boolean,
|
|
147
147
|
_silent: boolean,
|
|
148
|
-
|
|
148
|
+
_preserveExistingState: boolean,
|
|
149
149
|
): Promise<EnsureVersionResult> {
|
|
150
150
|
const result = await this.perform(
|
|
151
151
|
config,
|
|
@@ -188,6 +188,19 @@ export class RemoteOpenClawProvider implements OpenClawProvider {
|
|
|
188
188
|
await this.perform(config, "configure_channel", { channel }, dryRun);
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
async bindChannelAgent(config: OpenClawSection, channel: ChannelDef, agent: string, dryRun: boolean): Promise<void> {
|
|
192
|
+
await this.perform(
|
|
193
|
+
config,
|
|
194
|
+
"bind_channel_agent",
|
|
195
|
+
{
|
|
196
|
+
channel: channel.channel,
|
|
197
|
+
account: channel.account,
|
|
198
|
+
agent,
|
|
199
|
+
},
|
|
200
|
+
dryRun,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
191
204
|
async loginChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void> {
|
|
192
205
|
await this.perform(config, "login_channel", { channel }, dryRun);
|
|
193
206
|
}
|
package/src/orchestrator.ts
CHANGED
|
@@ -27,6 +27,26 @@ function truncateForLog(text: string, maxLength = 500): string {
|
|
|
27
27
|
return `${text.slice(0, maxLength)}... [truncated ${text.length - maxLength} chars]`;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
function renderTemplateString(input: string, vars: Record<string, string>, allowMissing: boolean): string {
|
|
31
|
+
return input.replace(/\$\{([^}]+)\}/g, (_match, rawKey: string) => {
|
|
32
|
+
const key = String(rawKey).trim();
|
|
33
|
+
if (!key) {
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
if (Object.prototype.hasOwnProperty.call(vars, key)) {
|
|
37
|
+
return vars[key] ?? "";
|
|
38
|
+
}
|
|
39
|
+
const lowerKey = key.toLowerCase();
|
|
40
|
+
if (Object.prototype.hasOwnProperty.call(vars, lowerKey)) {
|
|
41
|
+
return vars[lowerKey] ?? "";
|
|
42
|
+
}
|
|
43
|
+
if (allowMissing) {
|
|
44
|
+
return `\${${key}}`;
|
|
45
|
+
}
|
|
46
|
+
throw new ClawChefError(`Missing template variable in file content: ${key}`);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
30
50
|
function resolveWorkspacePath(recipeOrigin: RecipeOrigin, name: string, configuredPath?: string): string {
|
|
31
51
|
if (configuredPath?.trim()) {
|
|
32
52
|
if (path.isAbsolute(configuredPath)) {
|
|
@@ -146,19 +166,20 @@ export async function runRecipe(
|
|
|
146
166
|
const provider = createProvider(options);
|
|
147
167
|
const remoteMode = options.provider === "remote";
|
|
148
168
|
const workspacePaths = new Map<string, string>();
|
|
169
|
+
const preserveExistingState = options.scope !== "full";
|
|
149
170
|
|
|
150
171
|
logger.info(`Running recipe: ${recipe.name}`);
|
|
151
172
|
const versionResult = await provider.ensureVersion(
|
|
152
173
|
recipe.openclaw,
|
|
153
174
|
options.dryRun,
|
|
154
175
|
options.silent,
|
|
155
|
-
|
|
176
|
+
preserveExistingState,
|
|
156
177
|
);
|
|
157
178
|
logger.info(`OpenClaw version ready: ${recipe.openclaw.version}`);
|
|
158
179
|
|
|
159
180
|
if (versionResult.installedThisRun) {
|
|
160
181
|
logger.info("OpenClaw was installed in this run; skipping factory reset");
|
|
161
|
-
} else if (
|
|
182
|
+
} else if (preserveExistingState) {
|
|
162
183
|
logger.info("Keeping existing OpenClaw state; skipping factory reset");
|
|
163
184
|
} else {
|
|
164
185
|
const confirmed = await confirmFactoryReset(options);
|
|
@@ -240,67 +261,81 @@ export async function runRecipe(
|
|
|
240
261
|
}
|
|
241
262
|
|
|
242
263
|
for (const channel of recipe.channels ?? []) {
|
|
243
|
-
|
|
244
|
-
|
|
264
|
+
const effectiveChannel = channel.agent?.trim() && !channel.account?.trim()
|
|
265
|
+
? { ...channel, account: channel.agent.trim() }
|
|
266
|
+
: channel;
|
|
267
|
+
|
|
268
|
+
await provider.configureChannel(recipe.openclaw, effectiveChannel, options.dryRun);
|
|
269
|
+
logger.info(`Channel configured: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
|
|
270
|
+
if (effectiveChannel.agent?.trim()) {
|
|
271
|
+
await provider.bindChannelAgent(recipe.openclaw, effectiveChannel, effectiveChannel.agent, options.dryRun);
|
|
272
|
+
logger.info(
|
|
273
|
+
`Channel bound to agent: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""} -> ${effectiveChannel.agent}`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
245
276
|
}
|
|
246
277
|
|
|
247
|
-
for (const
|
|
248
|
-
const wsPath = workspacePaths.get(
|
|
278
|
+
for (const workspace of recipe.workspaces ?? []) {
|
|
279
|
+
const wsPath = workspacePaths.get(workspace.name);
|
|
249
280
|
if (!wsPath) {
|
|
250
|
-
throw new ClawChefError(`
|
|
281
|
+
throw new ClawChefError(`Workspace does not exist for files: ${workspace.name}`);
|
|
251
282
|
}
|
|
252
283
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
284
|
+
for (const file of workspace.files ?? []) {
|
|
285
|
+
if (provider.materializeFile) {
|
|
286
|
+
let content = file.content;
|
|
287
|
+
if (content === undefined && file.content_from) {
|
|
288
|
+
if (!options.dryRun) {
|
|
289
|
+
const rawContent = await readTextFromRef(recipeOrigin, file.content_from);
|
|
290
|
+
content = renderTemplateString(rawContent, options.vars, options.allowMissing);
|
|
291
|
+
} else {
|
|
292
|
+
const resolved = resolveFileRef(recipeOrigin, file.content_from);
|
|
293
|
+
content = `__dry_run_content_from__:${resolved.value}`;
|
|
294
|
+
}
|
|
261
295
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
296
|
+
if (content === undefined && file.source) {
|
|
297
|
+
if (!options.dryRun) {
|
|
298
|
+
content = await readTextFromRef(recipeOrigin, file.source);
|
|
299
|
+
} else {
|
|
300
|
+
const resolved = resolveFileRef(recipeOrigin, file.source);
|
|
301
|
+
content = `__dry_run_source__:${resolved.value}`;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (content === undefined) {
|
|
305
|
+
throw new ClawChefError(`File ${file.path} requires content, content_from, or source`);
|
|
269
306
|
}
|
|
270
|
-
}
|
|
271
|
-
if (content === undefined) {
|
|
272
|
-
throw new ClawChefError(`File ${file.path} requires content, content_from, or source`);
|
|
273
|
-
}
|
|
274
307
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
308
|
+
await provider.materializeFile(recipe.openclaw, workspace.name, file.path, content, file.overwrite, options.dryRun);
|
|
309
|
+
logger.info(`File materialized: ${workspace.name}/${file.path}`);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
279
312
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
313
|
+
const target = path.resolve(wsPath, file.path);
|
|
314
|
+
const targetDir = path.dirname(target);
|
|
315
|
+
|
|
316
|
+
if (!options.dryRun) {
|
|
317
|
+
await mkdir(targetDir, { recursive: true });
|
|
318
|
+
const alreadyExists = await exists(target);
|
|
319
|
+
if (alreadyExists && file.overwrite === false) {
|
|
320
|
+
logger.warn(`Skipping existing file: ${target}`);
|
|
321
|
+
} else if (file.content !== undefined) {
|
|
322
|
+
await writeFile(target, file.content, "utf8");
|
|
323
|
+
} else if (file.content_from) {
|
|
324
|
+
const rawContent = await readTextFromRef(recipeOrigin, file.content_from);
|
|
325
|
+
const content = renderTemplateString(rawContent, options.vars, options.allowMissing);
|
|
326
|
+
await writeFile(target, content, "utf8");
|
|
327
|
+
} else if (file.source) {
|
|
328
|
+
const resolved = resolveFileRef(recipeOrigin, file.source);
|
|
329
|
+
if (resolved.kind === "local") {
|
|
330
|
+
await copyFile(resolved.value, target);
|
|
331
|
+
} else {
|
|
332
|
+
const content = await readBinaryFromRef(recipeOrigin, file.source);
|
|
333
|
+
await writeFile(target, content);
|
|
334
|
+
}
|
|
300
335
|
}
|
|
301
336
|
}
|
|
337
|
+
logger.info(`File materialized: ${workspace.name}/${file.path}`);
|
|
302
338
|
}
|
|
303
|
-
logger.info(`File materialized: ${file.workspace}/${file.path}`);
|
|
304
339
|
}
|
|
305
340
|
|
|
306
341
|
for (const agent of recipe.agents ?? []) {
|
|
@@ -344,16 +379,19 @@ export async function runRecipe(
|
|
|
344
379
|
logger.info("Gateway started");
|
|
345
380
|
|
|
346
381
|
for (const channel of recipe.channels ?? []) {
|
|
347
|
-
|
|
382
|
+
const effectiveChannel = channel.agent?.trim() && !channel.account?.trim()
|
|
383
|
+
? { ...channel, account: channel.agent.trim() }
|
|
384
|
+
: channel;
|
|
385
|
+
if (!effectiveChannel.login) {
|
|
348
386
|
continue;
|
|
349
387
|
}
|
|
350
388
|
if (!options.dryRun && !input.isTTY) {
|
|
351
389
|
throw new ClawChefError(
|
|
352
|
-
`Channel login for ${
|
|
390
|
+
`Channel login for ${effectiveChannel.channel} requires an interactive terminal session`,
|
|
353
391
|
);
|
|
354
392
|
}
|
|
355
|
-
await provider.loginChannel(recipe.openclaw,
|
|
356
|
-
logger.info(`Channel login completed: ${
|
|
393
|
+
await provider.loginChannel(recipe.openclaw, effectiveChannel, options.dryRun);
|
|
394
|
+
logger.info(`Channel login completed: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
|
|
357
395
|
}
|
|
358
396
|
|
|
359
397
|
logger.info("Recipe execution completed");
|