clawchef 0.1.0 → 0.1.2

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.
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { homedir } from "node:os";
3
- import { mkdir, access, copyFile, writeFile, readFile } from "node:fs/promises";
3
+ import { mkdir, access, copyFile, writeFile, readFile, readdir, stat } from "node:fs/promises";
4
4
  import { constants } from "node:fs";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { stdin as input, stdout as output } from "node:process";
@@ -37,7 +37,9 @@ function resolveWorkspacePath(recipeOrigin: RecipeOrigin, name: string, configur
37
37
  }
38
38
  return path.resolve(configuredPath);
39
39
  }
40
- return path.join(homedir(), ".openclaw", "workspaces", name);
40
+ const trimmedName = name.trim() || name;
41
+ const workspaceName = trimmedName.startsWith("workspace-") ? trimmedName : `workspace-${trimmedName}`;
42
+ return path.join(homedir(), ".openclaw", workspaceName);
41
43
  }
42
44
 
43
45
  function isHttpUrl(value: string): boolean {
@@ -87,6 +89,35 @@ async function readBinaryFromRef(recipeOrigin: RecipeOrigin, reference: string):
87
89
  return Buffer.from(bytes);
88
90
  }
89
91
 
92
+ interface LocalAssetFile {
93
+ absolutePath: string;
94
+ relativePath: string;
95
+ }
96
+
97
+ async function collectLocalAssetFiles(rootDir: string, relDir = ""): Promise<LocalAssetFile[]> {
98
+ const currentDir = relDir ? path.join(rootDir, relDir) : rootDir;
99
+ const entries = await readdir(currentDir, { withFileTypes: true });
100
+ const out: LocalAssetFile[] = [];
101
+
102
+ for (const entry of entries) {
103
+ const nextRel = relDir ? path.join(relDir, entry.name) : entry.name;
104
+ if (entry.isDirectory()) {
105
+ out.push(...await collectLocalAssetFiles(rootDir, nextRel));
106
+ continue;
107
+ }
108
+ if (entry.isFile()) {
109
+ out.push({
110
+ absolutePath: path.join(rootDir, nextRel),
111
+ relativePath: nextRel,
112
+ });
113
+ continue;
114
+ }
115
+ throw new ClawChefError(`Unsupported entry in assets directory: ${path.join(rootDir, nextRel)}`);
116
+ }
117
+
118
+ return out;
119
+ }
120
+
90
121
  async function confirmFactoryReset(options: RunOptions): Promise<boolean> {
91
122
  if (options.silent || options.dryRun) {
92
123
  return true;
@@ -131,6 +162,13 @@ export async function runRecipe(
131
162
  logger.info("Factory reset completed");
132
163
  }
133
164
 
165
+ const pluginSpecs = Array.from(new Set([...(recipe.openclaw.plugins ?? []), ...options.plugins].map((v) => v.trim())))
166
+ .filter((v) => v.length > 0);
167
+ for (const pluginSpec of pluginSpecs) {
168
+ await provider.installPlugin(recipe.openclaw, pluginSpec, options.dryRun);
169
+ logger.info(`Plugin preinstalled: ${pluginSpec}`);
170
+ }
171
+
134
172
  for (const ws of recipe.workspaces ?? []) {
135
173
  const absPath = resolveWorkspacePath(recipeOrigin, ws.name, ws.path);
136
174
  workspacePaths.set(ws.name, absPath);
@@ -139,6 +177,50 @@ export async function runRecipe(
139
177
  }
140
178
  await provider.createWorkspace(recipe.openclaw, { ...ws, path: absPath }, options.dryRun);
141
179
  logger.info(`Workspace created: ${ws.name}`);
180
+
181
+ if (!ws.assets?.trim()) {
182
+ continue;
183
+ }
184
+
185
+ const resolvedAssets = resolveFileRef(recipeOrigin, ws.assets);
186
+ if (resolvedAssets.kind !== "local") {
187
+ throw new ClawChefError(
188
+ `Workspace assets must resolve to a local directory: ${ws.assets}. Direct URL recipes cannot use workspaces[].assets.`,
189
+ );
190
+ }
191
+
192
+ let assetDirStat;
193
+ try {
194
+ assetDirStat = await stat(resolvedAssets.value);
195
+ } catch (err) {
196
+ const message = err instanceof Error ? err.message : String(err);
197
+ throw new ClawChefError(`Workspace assets path is not accessible: ${resolvedAssets.value} (${message})`);
198
+ }
199
+ if (!assetDirStat.isDirectory()) {
200
+ throw new ClawChefError(`Workspace assets must be a directory: ${resolvedAssets.value}`);
201
+ }
202
+
203
+ const assetFiles = await collectLocalAssetFiles(resolvedAssets.value);
204
+ for (const assetFile of assetFiles) {
205
+ if (provider.materializeFile) {
206
+ const content = await readFile(assetFile.absolutePath, "utf8");
207
+ await provider.materializeFile(
208
+ recipe.openclaw,
209
+ ws.name,
210
+ assetFile.relativePath,
211
+ content,
212
+ true,
213
+ options.dryRun,
214
+ );
215
+ } else {
216
+ const target = path.resolve(absPath, assetFile.relativePath);
217
+ if (!options.dryRun) {
218
+ await mkdir(path.dirname(target), { recursive: true });
219
+ await copyFile(assetFile.absolutePath, target);
220
+ }
221
+ }
222
+ logger.info(`Workspace asset copied: ${ws.name}/${assetFile.relativePath}`);
223
+ }
142
224
  }
143
225
 
144
226
  for (const agent of recipe.agents ?? []) {
package/src/recipe.ts CHANGED
@@ -169,6 +169,11 @@ function collectVars(recipe: Recipe, cliVars: Record<string, string>): Record<st
169
169
 
170
170
  function semanticValidate(recipe: Recipe): void {
171
171
  const ws = new Set((recipe.workspaces ?? []).map((w) => w.name));
172
+ for (const workspace of recipe.workspaces ?? []) {
173
+ if (workspace.assets !== undefined && !workspace.assets.trim()) {
174
+ throw new ClawChefError(`Workspace ${workspace.name} has empty assets path`);
175
+ }
176
+ }
172
177
  for (const agent of recipe.agents ?? []) {
173
178
  if (!ws.has(agent.workspace)) {
174
179
  throw new ClawChefError(`Agent ${agent.name} references missing workspace: ${agent.workspace}`);
@@ -198,6 +203,15 @@ function semanticValidate(recipe: Recipe): void {
198
203
  );
199
204
  }
200
205
 
206
+ if (
207
+ channel.channel === "telegram" &&
208
+ (channel.login || channel.login_mode !== undefined || channel.login_account !== undefined)
209
+ ) {
210
+ throw new ClawChefError(
211
+ "channels[] entry for telegram does not support login/login_mode/login_account. Configure token (or use_env/token_file), then start gateway.",
212
+ );
213
+ }
214
+
201
215
  const hasAuth =
202
216
  Boolean(channel.use_env) ||
203
217
  Boolean(channel.token?.trim()) ||
@@ -0,0 +1,197 @@
1
+ import path from "node:path";
2
+ import { access, mkdir, readdir, writeFile } from "node:fs/promises";
3
+ import { constants } from "node:fs";
4
+ import { ClawChefError } from "./errors.js";
5
+
6
+ export interface ScaffoldOptions {
7
+ projectName?: string;
8
+ }
9
+
10
+ export interface ScaffoldResult {
11
+ targetDir: string;
12
+ projectName: string;
13
+ }
14
+
15
+ function normalizeProjectName(value: string): string {
16
+ const normalized = value
17
+ .trim()
18
+ .toLowerCase()
19
+ .replace(/[\s_]+/g, "-")
20
+ .replace(/[^a-z0-9-]/g, "-")
21
+ .replace(/-+/g, "-")
22
+ .replace(/^-|-$/g, "");
23
+ if (!normalized) {
24
+ throw new ClawChefError("Project name is empty after normalization. Use letters, numbers, spaces, underscore, or dash.");
25
+ }
26
+ return normalized;
27
+ }
28
+
29
+ async function pathExists(filePath: string): Promise<boolean> {
30
+ try {
31
+ await access(filePath, constants.F_OK);
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ async function ensureDirectoryEmpty(targetDir: string): Promise<void> {
39
+ const exists = await pathExists(targetDir);
40
+ if (!exists) {
41
+ await mkdir(targetDir, { recursive: true });
42
+ return;
43
+ }
44
+ const entries = await readdir(targetDir);
45
+ if (entries.length > 0) {
46
+ throw new ClawChefError(`Target directory is not empty: ${targetDir}`);
47
+ }
48
+ }
49
+
50
+ function makePackageJson(projectName: string): string {
51
+ const content = {
52
+ name: `${projectName}-recipe`,
53
+ version: "0.1.0",
54
+ private: true,
55
+ type: "module",
56
+ scripts: {
57
+ "test:recipe": "node --test test/recipe-smoke.test.mjs",
58
+ test: "node --test \"test/**/*.test.mjs\"",
59
+ },
60
+ devDependencies: {
61
+ "telegram-api-mock-server": "^0.1.5",
62
+ },
63
+ };
64
+ return `${JSON.stringify(content, null, 2)}\n`;
65
+ }
66
+
67
+ function makeRecipeYaml(projectName: string): string {
68
+ return `version: "1"
69
+ name: "${projectName}"
70
+
71
+ params:
72
+ openclaw_version:
73
+ default: "2026.2.9"
74
+ workspace_name:
75
+ default: "${projectName}"
76
+ agent_name:
77
+ default: "${projectName}"
78
+ agent_model:
79
+ default: "openai/gpt-4.1"
80
+ telegram_mock_api_key:
81
+ required: true
82
+
83
+ openclaw:
84
+ bin: "openclaw"
85
+ version: "\${openclaw_version}"
86
+ install: "never"
87
+ plugins:
88
+ - "openclaw-telegram-mock-channel"
89
+
90
+ workspaces:
91
+ - name: "\${workspace_name}"
92
+ assets: "./${projectName}-assets"
93
+
94
+ agents:
95
+ - workspace: "\${workspace_name}"
96
+ name: "\${agent_name}"
97
+ model: "\${agent_model}"
98
+
99
+ channels:
100
+ - channel: "telegram-mock"
101
+ account: "default"
102
+ token: "\${telegram_mock_api_key}"
103
+ extra_flags:
104
+ mock_bind: "127.0.0.1:18790"
105
+ mock_api_key: "\${telegram_mock_api_key}"
106
+ mode: "webhook"
107
+ `;
108
+ }
109
+
110
+ function makeAgentsDoc(projectName: string): string {
111
+ return `# ${projectName}
112
+
113
+ Project scaffold generated by clawchef.
114
+ `;
115
+ }
116
+
117
+ function makeIdentityDoc(projectName: string): string {
118
+ return `# Identity
119
+
120
+ You are the ${projectName} assistant.
121
+ `;
122
+ }
123
+
124
+ function makeSoulDoc(): string {
125
+ return `# Soul
126
+
127
+ Keep responses practical, concise, and action-oriented.
128
+ `;
129
+ }
130
+
131
+ function makeToolsDoc(): string {
132
+ return `# Tools
133
+
134
+ - Use available workspace files first.
135
+ - Ask for missing secrets explicitly.
136
+ `;
137
+ }
138
+
139
+ function makeSchedulingScript(): string {
140
+ return `#!/usr/bin/env node
141
+ import process from "node:process";
142
+
143
+ const message = process.argv.slice(2).join(" ").trim();
144
+ if (!message) {
145
+ console.error("Usage: node scripts/scheduling.mjs <message>");
146
+ process.exit(1);
147
+ }
148
+
149
+ console.log("[scheduling] " + message);
150
+ `;
151
+ }
152
+
153
+ function makeRecipeSmokeTest(): string {
154
+ return `import test from "node:test";
155
+ import assert from "node:assert/strict";
156
+ import { access } from "node:fs/promises";
157
+
158
+ test("recipe scaffold files exist", async () => {
159
+ await access("src/recipe.yaml");
160
+ await access("package.json");
161
+ assert.ok(true);
162
+ });
163
+ `;
164
+ }
165
+
166
+ export async function scaffoldProject(targetDirArg?: string, options: ScaffoldOptions = {}): Promise<ScaffoldResult> {
167
+ const targetDir = path.resolve(targetDirArg?.trim() ? targetDirArg : process.cwd());
168
+ await ensureDirectoryEmpty(targetDir);
169
+
170
+ const defaultName = path.basename(targetDir);
171
+ const rawProjectName = options.projectName?.trim() || defaultName;
172
+ const projectName = normalizeProjectName(rawProjectName);
173
+
174
+ const srcDir = path.join(targetDir, "src");
175
+ const assetsDir = path.join(srcDir, `${projectName}-assets`);
176
+ const assetsScriptsDir = path.join(assetsDir, "scripts");
177
+ const testDir = path.join(targetDir, "test");
178
+
179
+ await mkdir(srcDir, { recursive: true });
180
+ await mkdir(assetsDir, { recursive: true });
181
+ await mkdir(assetsScriptsDir, { recursive: true });
182
+ await mkdir(testDir, { recursive: true });
183
+
184
+ await writeFile(path.join(targetDir, "package.json"), makePackageJson(projectName), "utf8");
185
+ await writeFile(path.join(srcDir, "recipe.yaml"), makeRecipeYaml(projectName), "utf8");
186
+ await writeFile(path.join(assetsDir, "AGENTS.md"), makeAgentsDoc(projectName), "utf8");
187
+ await writeFile(path.join(assetsDir, "IDENTITY.md"), makeIdentityDoc(projectName), "utf8");
188
+ await writeFile(path.join(assetsDir, "SOUL.md"), makeSoulDoc(), "utf8");
189
+ await writeFile(path.join(assetsDir, "TOOLS.md"), makeToolsDoc(), "utf8");
190
+ await writeFile(path.join(assetsScriptsDir, "scheduling.mjs"), makeSchedulingScript(), "utf8");
191
+ await writeFile(path.join(testDir, "recipe-smoke.test.mjs"), makeRecipeSmokeTest(), "utf8");
192
+
193
+ return {
194
+ targetDir,
195
+ projectName,
196
+ };
197
+ }
package/src/schema.ts CHANGED
@@ -11,6 +11,7 @@ const openClawCommandsSchema = z
11
11
  use_version: z.string().optional(),
12
12
  install_version: z.string().optional(),
13
13
  uninstall_version: z.string().optional(),
14
+ install_plugin: z.string().optional(),
14
15
  factory_reset: z.string().optional(),
15
16
  start_gateway: z.string().optional(),
16
17
  enable_plugin: z.string().optional(),
@@ -58,6 +59,7 @@ const openClawSchema = z
58
59
  bin: z.string().optional(),
59
60
  version: z.string(),
60
61
  install: z.enum(["auto", "always", "never"]).optional(),
62
+ plugins: z.array(z.string().min(1)).optional(),
61
63
  bootstrap: openClawBootstrapSchema.optional(),
62
64
  commands: openClawCommandsSchema.optional(),
63
65
  })
@@ -67,6 +69,7 @@ const workspaceSchema = z
67
69
  .object({
68
70
  name: z.string().min(1),
69
71
  path: z.string().min(1).optional(),
72
+ assets: z.string().min(1).optional(),
70
73
  })
71
74
  .strict();
72
75
 
package/src/types.ts CHANGED
@@ -20,6 +20,7 @@ export interface OpenClawCommandOverrides {
20
20
  use_version?: string;
21
21
  install_version?: string;
22
22
  uninstall_version?: string;
23
+ install_plugin?: string;
23
24
  factory_reset?: string;
24
25
  start_gateway?: string;
25
26
  enable_plugin?: string;
@@ -63,6 +64,7 @@ export interface OpenClawSection {
63
64
  bin?: string;
64
65
  version: string;
65
66
  install?: InstallPolicy;
67
+ plugins?: string[];
66
68
  bootstrap?: OpenClawBootstrap;
67
69
  commands?: OpenClawCommandOverrides;
68
70
  }
@@ -70,6 +72,7 @@ export interface OpenClawSection {
70
72
  export interface WorkspaceDef {
71
73
  name: string;
72
74
  path?: string;
75
+ assets?: string;
73
76
  }
74
77
 
75
78
  export interface ChannelDef {
@@ -141,6 +144,7 @@ export interface Recipe {
141
144
 
142
145
  export interface RunOptions {
143
146
  vars: Record<string, string>;
147
+ plugins: string[];
144
148
  dryRun: boolean;
145
149
  allowMissing: boolean;
146
150
  verbose: boolean;