autoclaw 1.3.4 → 1.3.6
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 +356 -291
- package/README.zh-CN.md +69 -4
- package/dist/agent.js +47 -2
- package/dist/index.js +75 -1
- package/dist/sandbox.js +94 -0
- package/dist/shell.js +55 -32
- package/dist/skills.js +276 -0
- package/dist/tools/background.js +165 -0
- package/dist/tools/core.js +17 -1
- package/dist/tools/index.js +9 -1
- package/dist/tools/render-image.js +135 -0
- package/dist/tools/render-pdf.js +111 -0
- package/dist/tools/takumi-fonts.js +61 -0
- package/dist/zip.js +149 -0
- package/package.json +5 -2
- package/skills/code2media/SKILL.md +82 -0
- package/skills/code2media/references/syntax-guide.md +63 -0
- package/skills/code2media/scripts/package.json +10 -0
- package/skills/code2media/scripts/render.mjs +177 -0
- package/skills/code2media/templates/animation.html +19 -0
- package/skills/code2media/templates/badge.html +6 -0
- package/skills/code2media/templates/certificate.html +9 -0
- package/skills/code2media/templates/metrics-card.html +25 -0
- package/skills/code2media/templates/weekly-report.html +86 -0
- package/skills/invoice-maker/SKILL.md +65 -0
- package/skills/invoice-maker/references/syntax-guide.md +63 -0
- package/skills/invoice-maker/scripts/package.json +10 -0
- package/skills/invoice-maker/scripts/render.mjs +177 -0
- package/skills/invoice-maker/templates/invoice.html +26 -0
- package/skills/invoice-maker/templates/quote.html +68 -0
- package/skills/poster-maker/SKILL.md +62 -0
- package/skills/poster-maker/references/syntax-guide.md +63 -0
- package/skills/poster-maker/scripts/package.json +10 -0
- package/skills/poster-maker/scripts/render.mjs +177 -0
- package/skills/poster-maker/templates/cover.html +13 -0
- package/skills/poster-maker/templates/og-card.html +17 -0
- package/skills/poster-maker/templates/social-post.html +14 -0
package/dist/skills.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { createZip, readZip } from './zip.js';
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
// Built-in skills ship with the package (dist/../skills); user + project
|
|
8
|
+
// scopes shadow built-ins on name collisions (project > user > builtin).
|
|
9
|
+
export function builtinSkillsDir() {
|
|
10
|
+
return path.resolve(__dirname, '..', 'skills');
|
|
11
|
+
}
|
|
12
|
+
export function defaultSkillScopes() {
|
|
13
|
+
return [
|
|
14
|
+
{ dir: builtinSkillsDir(), source: 'builtin' },
|
|
15
|
+
{ dir: path.join(os.homedir(), '.autoclaw', 'skills'), source: 'user' },
|
|
16
|
+
{ dir: path.resolve(process.cwd(), '.autoclaw', 'skills'), source: 'project' },
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
export function parseSkillMd(raw) {
|
|
20
|
+
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
21
|
+
if (!match)
|
|
22
|
+
return null;
|
|
23
|
+
const frontmatter = {};
|
|
24
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
25
|
+
if (!line.trim() || line.trim().startsWith('#'))
|
|
26
|
+
continue;
|
|
27
|
+
const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
28
|
+
if (!kv)
|
|
29
|
+
continue;
|
|
30
|
+
let value = kv[2].trim();
|
|
31
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
32
|
+
value = value.slice(1, -1);
|
|
33
|
+
}
|
|
34
|
+
frontmatter[kv[1]] = value;
|
|
35
|
+
}
|
|
36
|
+
return { frontmatter, body: raw.slice(match[0].length) };
|
|
37
|
+
}
|
|
38
|
+
function toMeta(frontmatter, dir, source) {
|
|
39
|
+
const bool = (v) => v === 'true' ? true : v === 'false' ? false : undefined;
|
|
40
|
+
return {
|
|
41
|
+
name: frontmatter.name || path.basename(dir),
|
|
42
|
+
displayName: frontmatter.display_name,
|
|
43
|
+
description: frontmatter.description || frontmatter.description_zh || frontmatter.description_en || '',
|
|
44
|
+
descriptionZh: frontmatter.description_zh,
|
|
45
|
+
descriptionEn: frontmatter.description_en,
|
|
46
|
+
version: frontmatter.version,
|
|
47
|
+
author: frontmatter.author,
|
|
48
|
+
category: frontmatter.category,
|
|
49
|
+
disableModelInvocation: bool(frontmatter['disable-model-invocation']),
|
|
50
|
+
userInvocable: bool(frontmatter['user-invocable']),
|
|
51
|
+
source,
|
|
52
|
+
dir,
|
|
53
|
+
skillMdPath: path.join(dir, 'SKILL.md'),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
// Later scopes win on name collisions. Dirs starting with '.' are skipped.
|
|
57
|
+
export function discoverSkills(scopes) {
|
|
58
|
+
const warnings = [];
|
|
59
|
+
const byName = new Map();
|
|
60
|
+
for (const scope of scopes) {
|
|
61
|
+
let entries = [];
|
|
62
|
+
try {
|
|
63
|
+
entries = fs.readdirSync(scope.dir, { withFileTypes: true })
|
|
64
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
65
|
+
.map(e => e.name);
|
|
66
|
+
}
|
|
67
|
+
catch { /* scope dir missing — fine */
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
const dir = path.join(scope.dir, entry);
|
|
72
|
+
const skillMdPath = path.join(dir, 'SKILL.md');
|
|
73
|
+
let raw;
|
|
74
|
+
try {
|
|
75
|
+
raw = fs.readFileSync(skillMdPath, 'utf-8');
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
warnings.push(`skipped ${dir}: no SKILL.md`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const parsed = parseSkillMd(raw);
|
|
83
|
+
if (!parsed) {
|
|
84
|
+
warnings.push(`skipped ${skillMdPath}: missing YAML frontmatter`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const meta = toMeta(parsed.frontmatter, dir, scope.source);
|
|
88
|
+
if (!meta.description) {
|
|
89
|
+
warnings.push(`skipped ${skillMdPath}: empty description`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
byName.set(meta.name, meta);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
warnings.push(`skipped ${skillMdPath}: ${err?.message || err}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { skills: [...byName.values()], warnings };
|
|
100
|
+
}
|
|
101
|
+
// ---- system-prompt manifest (progressive disclosure: one line per skill) ----
|
|
102
|
+
function truncate(text, max) {
|
|
103
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
104
|
+
return flat.length > max ? flat.slice(0, max - 1) + '…' : flat;
|
|
105
|
+
}
|
|
106
|
+
export function buildSkillsManifest(config, scopes) {
|
|
107
|
+
if (config?.skillsEnabled === false)
|
|
108
|
+
return null;
|
|
109
|
+
const { skills } = discoverSkills(scopes || defaultSkillScopes());
|
|
110
|
+
const visible = skills.filter(s => s.disableModelInvocation !== true);
|
|
111
|
+
if (visible.length === 0)
|
|
112
|
+
return null;
|
|
113
|
+
const lines = visible.map(s => `- ${s.name}${s.version ? ` (v${s.version})` : ''}: ${truncate(s.description, 160)} [read ${s.skillMdPath}]`);
|
|
114
|
+
return [
|
|
115
|
+
'INSTALLED SKILL PACKAGES (procedural capabilities bundling instructions, scripts and templates).',
|
|
116
|
+
'When a task matches a skill, first read its SKILL.md and follow it — skills run through your normal file and shell tools, no special API:',
|
|
117
|
+
...lines,
|
|
118
|
+
].join('\n');
|
|
119
|
+
}
|
|
120
|
+
// ---- install / remove ----
|
|
121
|
+
const INSTALL_SKIP = new Set(['node_modules', '.git', '__MACOSX']);
|
|
122
|
+
function isJunkPath(rel) {
|
|
123
|
+
return rel.split('/').some(s => INSTALL_SKIP.has(s)) || rel.split('/').pop() === '.DS_Store';
|
|
124
|
+
}
|
|
125
|
+
// Skill directory names come from untrusted frontmatter, so restrict to
|
|
126
|
+
// letters (any script, e.g. Chinese), digits, dot, underscore, dash — no
|
|
127
|
+
// separators, no leading dot (discovery skips dot-dirs), bounded length.
|
|
128
|
+
function isSafeSkillName(name) {
|
|
129
|
+
return name.length <= 64 && /^[\p{L}\p{N}][\p{L}\p{N}._-]*$/u.test(name);
|
|
130
|
+
}
|
|
131
|
+
function copyTree(src, dest) {
|
|
132
|
+
let count = 0;
|
|
133
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
134
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
135
|
+
if (INSTALL_SKIP.has(entry.name) || entry.name === '.DS_Store')
|
|
136
|
+
continue;
|
|
137
|
+
const s = path.join(src, entry.name);
|
|
138
|
+
const d = path.join(dest, entry.name);
|
|
139
|
+
if (entry.isDirectory())
|
|
140
|
+
count += copyTree(s, d);
|
|
141
|
+
else if (entry.isFile()) {
|
|
142
|
+
fs.copyFileSync(s, d);
|
|
143
|
+
count++;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return count;
|
|
147
|
+
}
|
|
148
|
+
export function userSkillsDir() {
|
|
149
|
+
return path.join(os.homedir(), '.autoclaw', 'skills');
|
|
150
|
+
}
|
|
151
|
+
// Install from a skill directory, a zip package, or an https URL into the
|
|
152
|
+
// user scope (~/.autoclaw/skills/). The installed directory is named after
|
|
153
|
+
// the skill's frontmatter `name` (fallback: the folder/zip stem), so the
|
|
154
|
+
// path always matches what discovery and the manifest expect.
|
|
155
|
+
export function installSkill(target, opts) {
|
|
156
|
+
const userDir = opts?.userDir || userSkillsDir();
|
|
157
|
+
const stat = fs.existsSync(target) ? fs.statSync(target) : null;
|
|
158
|
+
if (!stat)
|
|
159
|
+
throw new Error(`target not found: ${target}`);
|
|
160
|
+
if (stat.isDirectory()) {
|
|
161
|
+
const skillMdPath = path.join(target, 'SKILL.md');
|
|
162
|
+
if (!fs.existsSync(skillMdPath))
|
|
163
|
+
throw new Error(`${target} is not a skill (no SKILL.md)`);
|
|
164
|
+
const parsed = parseSkillMd(fs.readFileSync(skillMdPath, 'utf-8'));
|
|
165
|
+
const name = pickSafeName(parsed?.frontmatter.name, path.basename(path.resolve(target)));
|
|
166
|
+
const dest = path.join(userDir, name);
|
|
167
|
+
const files = copyTree(target, dest);
|
|
168
|
+
return { name, dir: dest, files };
|
|
169
|
+
}
|
|
170
|
+
// zip package: locate the SKILL.md entry, rebase its dir to userDir/<name>.
|
|
171
|
+
// Works with third-party layouts: SKILL.md at the zip root, a plain folder,
|
|
172
|
+
// or a skills/<name>/ wrapper, independently of frontmatter-vs-folder naming.
|
|
173
|
+
const buf = fs.readFileSync(target);
|
|
174
|
+
const entries = readZip(buf).map(e => ({ ...e, path: e.path.replace(/\\/g, '/') }));
|
|
175
|
+
const candidates = entries
|
|
176
|
+
.filter(e => /(^|\/)SKILL\.md$/.test(e.path))
|
|
177
|
+
.sort((a, b) => a.path.split('/').length - b.path.split('/').length);
|
|
178
|
+
if (candidates.length === 0)
|
|
179
|
+
throw new Error('zip contains no SKILL.md — not a skill package');
|
|
180
|
+
const pick = candidates[0];
|
|
181
|
+
const rootDir = pick.path.split('/').slice(0, -1).join('/');
|
|
182
|
+
const parsed = parseSkillMd(pick.data.toString('utf-8'));
|
|
183
|
+
const fallback = rootDir ? rootDir.split('/').filter(Boolean).pop() : path.basename(target).replace(/\.zip$/i, '');
|
|
184
|
+
const name = pickSafeName(parsed?.frontmatter.name, fallback);
|
|
185
|
+
const prefix = rootDir ? rootDir + '/' : '';
|
|
186
|
+
const dest = path.join(userDir, name);
|
|
187
|
+
const destResolved = path.resolve(dest) + path.sep;
|
|
188
|
+
const wanted = entries
|
|
189
|
+
.filter(e => prefix ? e.path.startsWith(prefix) : true)
|
|
190
|
+
.map(e => ({ rel: prefix ? e.path.slice(prefix.length) : e.path, data: e.data }))
|
|
191
|
+
.filter(e => e.rel !== '' && !e.rel.endsWith('/') && !isJunkPath(e.rel));
|
|
192
|
+
// Validate every target path before writing anything: a hostile entry must
|
|
193
|
+
// reject the whole archive, not half-install it.
|
|
194
|
+
const targets = wanted.map(e => {
|
|
195
|
+
const destFile = path.resolve(dest, ...e.rel.split('/'));
|
|
196
|
+
if (!destFile.startsWith(destResolved))
|
|
197
|
+
throw new Error(`unsafe zip entry: ${e.rel}`);
|
|
198
|
+
return { destFile, data: e.data };
|
|
199
|
+
});
|
|
200
|
+
for (const t of targets) {
|
|
201
|
+
fs.mkdirSync(path.dirname(t.destFile), { recursive: true });
|
|
202
|
+
fs.writeFileSync(t.destFile, t.data);
|
|
203
|
+
}
|
|
204
|
+
return { name, dir: dest, files: targets.length };
|
|
205
|
+
}
|
|
206
|
+
function pickSafeName(primary, fallback) {
|
|
207
|
+
const candidate = primary && isSafeSkillName(primary) ? primary : fallback;
|
|
208
|
+
if (!isSafeSkillName(candidate)) {
|
|
209
|
+
throw new Error(`unsafe skill name: ${JSON.stringify(primary || fallback)}`);
|
|
210
|
+
}
|
|
211
|
+
return candidate;
|
|
212
|
+
}
|
|
213
|
+
// Download an https zip package to a temp file and install it.
|
|
214
|
+
export async function installSkillFromUrl(url, opts) {
|
|
215
|
+
if (!/^https:\/\//.test(url))
|
|
216
|
+
throw new Error('only https URLs are supported');
|
|
217
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(120000) });
|
|
218
|
+
if (!res.ok)
|
|
219
|
+
throw new Error(`download failed: HTTP ${res.status} for ${url}`);
|
|
220
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
221
|
+
if (buf.length > 100 * 1024 * 1024)
|
|
222
|
+
throw new Error('skill package exceeds 100 MB limit');
|
|
223
|
+
const tmp = path.join(os.tmpdir(), `autoclaw-skill-${Date.now()}-${Math.random().toString(36).slice(2)}.zip`);
|
|
224
|
+
fs.writeFileSync(tmp, buf);
|
|
225
|
+
try {
|
|
226
|
+
return installSkill(tmp, opts);
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
fs.rmSync(tmp, { force: true });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
export function removeSkill(name, opts) {
|
|
233
|
+
const scopes = opts?.scopes || defaultSkillScopes();
|
|
234
|
+
const { skills } = discoverSkills(scopes);
|
|
235
|
+
const skill = skills.find(s => s.name === name);
|
|
236
|
+
if (!skill)
|
|
237
|
+
return 'not-found';
|
|
238
|
+
if (skill.source === 'builtin')
|
|
239
|
+
return 'builtin';
|
|
240
|
+
if (skill.source === 'project')
|
|
241
|
+
return 'project';
|
|
242
|
+
const userDir = path.resolve(opts?.userDir || userSkillsDir());
|
|
243
|
+
const dir = path.resolve(skill.dir);
|
|
244
|
+
if (!dir.startsWith(userDir + path.sep))
|
|
245
|
+
return 'not-found'; // never delete outside the user scope
|
|
246
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
247
|
+
return 'removed';
|
|
248
|
+
}
|
|
249
|
+
// ---- pack (store-upload artifact: zip with skills/<name>/ at its root) ----
|
|
250
|
+
export function packSkill(dir, outPath) {
|
|
251
|
+
const abs = path.resolve(dir);
|
|
252
|
+
const skillMdPath = path.join(abs, 'SKILL.md');
|
|
253
|
+
if (!fs.existsSync(skillMdPath))
|
|
254
|
+
throw new Error(`${abs} is not a skill (no SKILL.md)`);
|
|
255
|
+
const parsed = parseSkillMd(fs.readFileSync(skillMdPath, 'utf-8'));
|
|
256
|
+
const name = parsed?.frontmatter.name || path.basename(abs);
|
|
257
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name))
|
|
258
|
+
throw new Error(`unsafe skill name: ${name}`);
|
|
259
|
+
const files = [];
|
|
260
|
+
const walk = (current, rel) => {
|
|
261
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
262
|
+
if (INSTALL_SKIP.has(entry.name) || entry.name === '.DS_Store')
|
|
263
|
+
continue;
|
|
264
|
+
const child = path.join(current, entry.name);
|
|
265
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
266
|
+
if (entry.isDirectory())
|
|
267
|
+
walk(child, childRel);
|
|
268
|
+
else if (entry.isFile())
|
|
269
|
+
files.push({ path: `skills/${name}/${childRel}`, data: fs.readFileSync(child) });
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
walk(abs, '');
|
|
273
|
+
const zipPath = outPath || path.resolve(process.cwd(), `${name}-skill.zip`);
|
|
274
|
+
fs.writeFileSync(zipPath, createZip(files));
|
|
275
|
+
return { zipPath, fileCount: files.length, name };
|
|
276
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import { killProcessTree, shellInvocation, spawnEnv } from '../shell.js';
|
|
6
|
+
import { resolveSandboxMode, sandboxBackend, sandboxedInvocation } from '../sandbox.js';
|
|
7
|
+
import { matchDangerousPattern } from './core.js';
|
|
8
|
+
const handles = new Map();
|
|
9
|
+
const MAX_TRACKED = 20;
|
|
10
|
+
function newHandleId() {
|
|
11
|
+
return `bg-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`;
|
|
12
|
+
}
|
|
13
|
+
function tailOf(logPath, bytes = 4000) {
|
|
14
|
+
try {
|
|
15
|
+
if (!fs.existsSync(logPath))
|
|
16
|
+
return '(no output yet)';
|
|
17
|
+
const stat = fs.statSync(logPath);
|
|
18
|
+
const start = Math.max(0, stat.size - bytes);
|
|
19
|
+
const fd = fs.openSync(logPath, 'r');
|
|
20
|
+
try {
|
|
21
|
+
const buf = Buffer.alloc(stat.size - start);
|
|
22
|
+
fs.readSync(fd, buf, 0, buf.length, start);
|
|
23
|
+
return (start > 0 ? '…(earlier output omitted)\n' : '') + buf.toString('utf-8');
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
fs.closeSync(fd);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
return `(could not read log: ${err.message})`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export const StartBackgroundProcessTool = {
|
|
34
|
+
name: "Background Process Starter",
|
|
35
|
+
definition: {
|
|
36
|
+
type: "function",
|
|
37
|
+
function: {
|
|
38
|
+
name: "start_background_process",
|
|
39
|
+
description: "Start a long-lived command in the background without blocking: dev servers, watchers, long builds. Output is written to a log file you can read later.",
|
|
40
|
+
parameters: {
|
|
41
|
+
type: "object",
|
|
42
|
+
properties: {
|
|
43
|
+
command: { type: "string", description: "The command to run in the background." },
|
|
44
|
+
rationale: { type: "string", description: "Explain why this command should run in the background." }
|
|
45
|
+
},
|
|
46
|
+
required: ["command", "rationale"]
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
handler: async (args, config) => {
|
|
51
|
+
const command = String(args.command ?? '').trim();
|
|
52
|
+
if (!command)
|
|
53
|
+
return 'Error: "command" is required.';
|
|
54
|
+
// Same safety gate as the foreground shell tool.
|
|
55
|
+
const gateLabel = matchDangerousPattern(command);
|
|
56
|
+
if (gateLabel) {
|
|
57
|
+
return `Error: command blocked by AutoClaw safety policy (matched: ${gateLabel}). It was NOT started. If this task genuinely requires it, restart AutoClaw with --allow-dangerous.`;
|
|
58
|
+
}
|
|
59
|
+
// Same sandbox policy as the foreground shell tool.
|
|
60
|
+
const sandboxMode = resolveSandboxMode(config);
|
|
61
|
+
let target = shellInvocation(command);
|
|
62
|
+
if (sandboxMode !== 'danger-full-access') {
|
|
63
|
+
const backend = sandboxBackend();
|
|
64
|
+
if (!backend.available) {
|
|
65
|
+
return `Error: sandbox mode "${sandboxMode}" is configured but no sandbox backend is available: ${backend.detail}`;
|
|
66
|
+
}
|
|
67
|
+
const inv = sandboxedInvocation(command, sandboxMode, process.cwd());
|
|
68
|
+
if ('blocked' in inv) {
|
|
69
|
+
return `Error: sandbox mode "${sandboxMode}" cannot wrap this command: ${inv.detail}`;
|
|
70
|
+
}
|
|
71
|
+
target = inv;
|
|
72
|
+
}
|
|
73
|
+
for (const [id, h] of handles) {
|
|
74
|
+
if (h.exited && Date.now() - h.startedAt > 3600_000)
|
|
75
|
+
handles.delete(id);
|
|
76
|
+
}
|
|
77
|
+
if (handles.size >= MAX_TRACKED) {
|
|
78
|
+
return `Error: too many tracked background processes (${MAX_TRACKED}). Stop finished ones first or wait for them to exit.`;
|
|
79
|
+
}
|
|
80
|
+
const id = newHandleId();
|
|
81
|
+
const logDir = path.join(os.homedir(), '.autoclaw', 'output');
|
|
82
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
83
|
+
const logPath = path.join(logDir, `bg-${id}.log`);
|
|
84
|
+
const { file: logFile, args: spawnArgs } = target;
|
|
85
|
+
const out = fs.openSync(logPath, 'a');
|
|
86
|
+
let child;
|
|
87
|
+
try {
|
|
88
|
+
child = spawn(logFile, spawnArgs, {
|
|
89
|
+
windowsHide: true,
|
|
90
|
+
detached: process.platform !== 'win32',
|
|
91
|
+
env: spawnEnv(),
|
|
92
|
+
stdio: ['ignore', out, out]
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
fs.closeSync(out);
|
|
97
|
+
return `Error: failed to start background process: ${err.message}`;
|
|
98
|
+
}
|
|
99
|
+
fs.closeSync(out);
|
|
100
|
+
const entry = { id, pid: child.pid, command, logPath, startedAt: Date.now(), exited: false, exitCode: null };
|
|
101
|
+
handles.set(id, entry);
|
|
102
|
+
child.on('exit', code => { entry.exited = true; entry.exitCode = code ?? null; });
|
|
103
|
+
child.unref();
|
|
104
|
+
return `Background process started.\n- handle id: ${id}\n- pid: ${child.pid}\n- log: ${logPath}\nUse check_background_process with this id to read output, or stop_background_process to terminate it.`;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
export const CheckBackgroundProcessTool = {
|
|
108
|
+
name: "Background Process Checker",
|
|
109
|
+
definition: {
|
|
110
|
+
type: "function",
|
|
111
|
+
function: {
|
|
112
|
+
name: "check_background_process",
|
|
113
|
+
description: "Check a background process started with start_background_process: running/exited status and the tail of its output log.",
|
|
114
|
+
parameters: {
|
|
115
|
+
type: "object",
|
|
116
|
+
properties: {
|
|
117
|
+
id: { type: "string", description: "The handle id returned by start_background_process." }
|
|
118
|
+
},
|
|
119
|
+
required: ["id"]
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
handler: async (args) => {
|
|
124
|
+
const id = String(args.id ?? '');
|
|
125
|
+
const entry = handles.get(id);
|
|
126
|
+
if (!entry) {
|
|
127
|
+
return `Error: unknown background process id "${id}". Handles live for the current AutoClaw process only; the log file may still exist under ~/.autoclaw/output/.`;
|
|
128
|
+
}
|
|
129
|
+
const status = entry.exited
|
|
130
|
+
? `exited (code: ${entry.exitCode ?? 'unknown'})`
|
|
131
|
+
: `running (pid: ${entry.pid}, ${Math.round((Date.now() - entry.startedAt) / 1000)}s elapsed)`;
|
|
132
|
+
return `Background process ${id}: ${status}\nCommand: ${entry.command}\nLog tail:\n${tailOf(entry.logPath)}`;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
export const StopBackgroundProcessTool = {
|
|
136
|
+
name: "Background Process Stopper",
|
|
137
|
+
definition: {
|
|
138
|
+
type: "function",
|
|
139
|
+
function: {
|
|
140
|
+
name: "stop_background_process",
|
|
141
|
+
description: "Terminate a background process started with start_background_process (kills the whole process tree).",
|
|
142
|
+
parameters: {
|
|
143
|
+
type: "object",
|
|
144
|
+
properties: {
|
|
145
|
+
id: { type: "string", description: "The handle id returned by start_background_process." }
|
|
146
|
+
},
|
|
147
|
+
required: ["id"]
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
handler: async (args) => {
|
|
152
|
+
const id = String(args.id ?? '');
|
|
153
|
+
const entry = handles.get(id);
|
|
154
|
+
if (!entry) {
|
|
155
|
+
return `Error: unknown background process id "${id}".`;
|
|
156
|
+
}
|
|
157
|
+
if (entry.exited) {
|
|
158
|
+
return `Background process ${id} already exited (code: ${entry.exitCode ?? 'unknown'}). Log: ${entry.logPath}`;
|
|
159
|
+
}
|
|
160
|
+
if (entry.pid)
|
|
161
|
+
killProcessTree(entry.pid);
|
|
162
|
+
entry.exited = true;
|
|
163
|
+
return `Background process ${id} terminated.\nLog: ${entry.logPath}\nLog tail:\n${tailOf(entry.logPath)}`;
|
|
164
|
+
}
|
|
165
|
+
};
|
package/dist/tools/core.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as path from 'path';
|
|
|
3
3
|
import inquirer from 'inquirer';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import { execShellCommand } from '../shell.js';
|
|
6
|
+
import { resolveSandboxMode, sandboxBackend, sandboxedInvocation } from '../sandbox.js';
|
|
6
7
|
import * as os from 'os';
|
|
7
8
|
const DEFAULT_SHELL_TIMEOUT_MS = 120000;
|
|
8
9
|
const SHELL_MAX_BUFFER = 10 * 1024 * 1024;
|
|
@@ -75,6 +76,21 @@ export const ShellTool = {
|
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
78
|
const timeoutMs = Number(config?.shellTimeout || process.env.AUTOCLOW_SHELL_TIMEOUT || DEFAULT_SHELL_TIMEOUT_MS);
|
|
79
|
+
// Sandbox policy: non-default modes wrap the invocation in a platform
|
|
80
|
+
// backend, or refuse (fail-closed) when no backend exists.
|
|
81
|
+
const sandboxMode = resolveSandboxMode(config);
|
|
82
|
+
let sandboxed;
|
|
83
|
+
if (sandboxMode !== 'danger-full-access') {
|
|
84
|
+
const backend = sandboxBackend();
|
|
85
|
+
if (!backend.available) {
|
|
86
|
+
return `Error: sandbox mode "${sandboxMode}" is configured but no sandbox backend is available: ${backend.detail}`;
|
|
87
|
+
}
|
|
88
|
+
const inv = sandboxedInvocation(args.command, sandboxMode, process.cwd());
|
|
89
|
+
if ('blocked' in inv) {
|
|
90
|
+
return `Error: sandbox mode "${sandboxMode}" cannot wrap this command: ${inv.detail}`;
|
|
91
|
+
}
|
|
92
|
+
sandboxed = inv;
|
|
93
|
+
}
|
|
78
94
|
// Check for auto-confirm flag
|
|
79
95
|
if (!config?.autoConfirm) {
|
|
80
96
|
if (!process.stdin.isTTY) {
|
|
@@ -97,7 +113,7 @@ export const ShellTool = {
|
|
|
97
113
|
console.log(chalk.gray("(Auto-confirming command execution due to --yes flag)"));
|
|
98
114
|
}
|
|
99
115
|
try {
|
|
100
|
-
const r = await execShellCommand(args.command, { timeoutMs, maxBuffer: SHELL_MAX_BUFFER });
|
|
116
|
+
const r = await execShellCommand(args.command, { timeoutMs, maxBuffer: SHELL_MAX_BUFFER, sandboxed });
|
|
101
117
|
if (r.timedOut) {
|
|
102
118
|
return `Command timed out after ${timeoutMs}ms and was terminated.\nStdout: ${r.stdout}\nStderr: ${r.stderr}`;
|
|
103
119
|
}
|
package/dist/tools/index.js
CHANGED
|
@@ -6,6 +6,9 @@ import { BrowserTool } from './browser.js';
|
|
|
6
6
|
import { ScreenshotTool } from './screenshot.js';
|
|
7
7
|
import { ImageTool } from './image.js';
|
|
8
8
|
import { PromptOptimizerTool } from './prompt-optimizer.js';
|
|
9
|
+
import { RenderImageTool } from './render-image.js';
|
|
10
|
+
import { RenderPdfTool } from './render-pdf.js';
|
|
11
|
+
import { CheckBackgroundProcessTool, StartBackgroundProcessTool, StopBackgroundProcessTool } from './background.js';
|
|
9
12
|
// Central Registry of all available tools
|
|
10
13
|
export const toolRegistry = [
|
|
11
14
|
ShellTool,
|
|
@@ -18,7 +21,12 @@ export const toolRegistry = [
|
|
|
18
21
|
NotifyTool,
|
|
19
22
|
BrowserTool,
|
|
20
23
|
ScreenshotTool,
|
|
21
|
-
ImageTool
|
|
24
|
+
ImageTool,
|
|
25
|
+
RenderImageTool,
|
|
26
|
+
RenderPdfTool,
|
|
27
|
+
StartBackgroundProcessTool,
|
|
28
|
+
CheckBackgroundProcessTool,
|
|
29
|
+
StopBackgroundProcessTool
|
|
22
30
|
];
|
|
23
31
|
export function getToolDefinitions(config) {
|
|
24
32
|
return toolRegistry
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { render, renderSvg, renderAnimation } from 'takumi-js';
|
|
4
|
+
import { resolveFontLoaders, describeFonts } from './takumi-fonts.js';
|
|
5
|
+
const VALID_FORMATS = ['png', 'jpeg', 'webp', 'svg'];
|
|
6
|
+
const VALID_ANIMATION_FORMATS = ['webp', 'gif', 'apng'];
|
|
7
|
+
const toolDefinition = {
|
|
8
|
+
type: "function",
|
|
9
|
+
function: {
|
|
10
|
+
name: "render_image",
|
|
11
|
+
description: "Render an HTML template into a precise, deterministic image (PNG/JPEG/WebP), vector SVG, or animation (animated WebP/GIF/APNG from CSS @keyframes). Fully offline — no AI, no network, no browser, renders in milliseconds. Use this when exact text, layout, colors or data matter: OG/share cards, banners, badges, certificates, data cards, charts built from divs, simple motion graphics. Style with the 'tw' attribute (Tailwind v4 utilities, e.g. <div tw=\"w-full h-full bg-blue-500\">), inline styles, or a <style> block with regular CSS classes — Tailwind utilities placed in the class attribute are NOT compiled, always use tw for them. JavaScript is NOT executed; remote images in the template would require network. For artistic or photographic imagery use generate_image instead. CJK/emoji text needs fonts: common system fonts are auto-detected when font_paths is omitted, and registered families can be referenced via font-family.",
|
|
12
|
+
parameters: {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: {
|
|
15
|
+
template: {
|
|
16
|
+
type: "string",
|
|
17
|
+
description: "HTML fragment to render. Size the root to fill the canvas, e.g. '<div tw=\"w-full h-full flex flex-col items-center justify-center bg-linear-to-br from-blue-600 to-indigo-900 p-12\"><h1 tw=\"text-6xl font-bold text-white\">Monthly Report</h1></div>'. Style via the tw attribute (canonical Tailwind v4 names only — v3 names like bg-gradient-to-* are not supported, use bg-linear-to-*), inline styles, or a <style> block; a plain class attribute only matches CSS selectors, not Tailwind utilities."
|
|
18
|
+
},
|
|
19
|
+
css: {
|
|
20
|
+
type: "string",
|
|
21
|
+
description: "Optional CSS stylesheet applied before rendering, e.g. 'h1 { letter-spacing: 2px; }'."
|
|
22
|
+
},
|
|
23
|
+
width: {
|
|
24
|
+
type: "integer",
|
|
25
|
+
description: "Canvas width in px. Default 1200."
|
|
26
|
+
},
|
|
27
|
+
height: {
|
|
28
|
+
type: "integer",
|
|
29
|
+
description: "Canvas height in px. Default 630 (standard OG-image size)."
|
|
30
|
+
},
|
|
31
|
+
format: {
|
|
32
|
+
type: "string",
|
|
33
|
+
enum: VALID_FORMATS,
|
|
34
|
+
description: "Output format. Default 'png'. 'svg' produces a vector document instead of a bitmap. Ignored when 'animation' is set."
|
|
35
|
+
},
|
|
36
|
+
animation: {
|
|
37
|
+
type: "object",
|
|
38
|
+
description: "Render an animated image instead of a static one: CSS @keyframes in the template (via a <style> block or the animation shorthand) are sampled across the scene duration. When set, 'format' is ignored — the container format comes from animation.format.",
|
|
39
|
+
properties: {
|
|
40
|
+
duration_ms: { type: "integer", description: "Scene duration in milliseconds. Default 1000." },
|
|
41
|
+
fps: { type: "integer", description: "Frames per second. Default 30." },
|
|
42
|
+
format: { type: "string", enum: VALID_ANIMATION_FORMATS, description: "Animation container format. Default 'webp'." }
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
quality: {
|
|
46
|
+
type: "integer",
|
|
47
|
+
description: "0-100, JPEG/WebP only. Omit for lossless WebP / default JPEG quality."
|
|
48
|
+
},
|
|
49
|
+
font_paths: {
|
|
50
|
+
type: "array",
|
|
51
|
+
items: { type: "string" },
|
|
52
|
+
description: "Local font files (.ttf/.otf/.ttc/.woff2) to register. Omit to auto-detect common system fonts (incl. CJK/emoji). Pass [] to skip font loading (built-in Latin font only)."
|
|
53
|
+
},
|
|
54
|
+
output_path: {
|
|
55
|
+
type: "string",
|
|
56
|
+
description: "File path to write the rendered image. Parent directories are created automatically."
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
required: ["template", "output_path"]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const handler = async (args, config) => {
|
|
64
|
+
const template = typeof args.template === 'string' ? args.template : '';
|
|
65
|
+
if (!template.trim()) {
|
|
66
|
+
return "Error: 'template' is required — an HTML fragment to render.";
|
|
67
|
+
}
|
|
68
|
+
if (!args.output_path) {
|
|
69
|
+
return "Error: 'output_path' is required.";
|
|
70
|
+
}
|
|
71
|
+
const format = String(args.format || 'png').toLowerCase();
|
|
72
|
+
if (!VALID_FORMATS.includes(format)) {
|
|
73
|
+
return `Error: Invalid format '${format}'. Supported formats: ${VALID_FORMATS.join(", ")}.`;
|
|
74
|
+
}
|
|
75
|
+
const anim = args.animation && typeof args.animation === 'object' ? args.animation : null;
|
|
76
|
+
if (anim && anim.format != null && !VALID_ANIMATION_FORMATS.includes(String(anim.format).toLowerCase())) {
|
|
77
|
+
return `Error: Invalid animation format '${anim.format}'. Supported: ${VALID_ANIMATION_FORMATS.join(", ")}.`;
|
|
78
|
+
}
|
|
79
|
+
const width = args.width || 1200;
|
|
80
|
+
const height = args.height || 630;
|
|
81
|
+
const resolvedPath = path.resolve(process.cwd(), args.output_path);
|
|
82
|
+
try {
|
|
83
|
+
const resolved = resolveFontLoaders(args.font_paths);
|
|
84
|
+
const fontsInfo = describeFonts(resolved);
|
|
85
|
+
fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
|
|
86
|
+
if (anim) {
|
|
87
|
+
const animFormat = String(anim.format || 'webp').toLowerCase();
|
|
88
|
+
const options = {
|
|
89
|
+
width,
|
|
90
|
+
height,
|
|
91
|
+
fps: anim.fps || 30,
|
|
92
|
+
format: animFormat,
|
|
93
|
+
scenes: [{ node: template, durationMs: anim.duration_ms || 1000 }]
|
|
94
|
+
};
|
|
95
|
+
if (resolved.fonts.length > 0)
|
|
96
|
+
options.fonts = resolved.fonts;
|
|
97
|
+
if (args.css)
|
|
98
|
+
options.css = args.css;
|
|
99
|
+
const bytes = await renderAnimation(options);
|
|
100
|
+
const buffer = Buffer.from(bytes);
|
|
101
|
+
fs.writeFileSync(resolvedPath, buffer);
|
|
102
|
+
return `Rendered animation (${width}x${height} ${animFormat.toUpperCase()}, ${anim.fps || 30}fps, ${anim.duration_ms || 1000}ms, ${buffer.length} bytes) saved to ${resolvedPath}. Fonts: ${fontsInfo}`;
|
|
103
|
+
}
|
|
104
|
+
if (format === 'svg') {
|
|
105
|
+
const options = { width, height };
|
|
106
|
+
if (resolved.fonts.length > 0)
|
|
107
|
+
options.fonts = resolved.fonts;
|
|
108
|
+
if (args.css)
|
|
109
|
+
options.css = args.css;
|
|
110
|
+
const svg = await renderSvg(template, options);
|
|
111
|
+
fs.writeFileSync(resolvedPath, svg, 'utf-8');
|
|
112
|
+
return `Rendered vector SVG (${width}x${height}, ${Buffer.byteLength(svg)} bytes) saved to ${resolvedPath}. Fonts: ${fontsInfo}`;
|
|
113
|
+
}
|
|
114
|
+
const options = { width, height, format };
|
|
115
|
+
if (resolved.fonts.length > 0)
|
|
116
|
+
options.fonts = resolved.fonts;
|
|
117
|
+
if (args.css)
|
|
118
|
+
options.css = args.css;
|
|
119
|
+
if (args.quality != null && (format === 'jpeg' || format === 'webp')) {
|
|
120
|
+
options.quality = args.quality;
|
|
121
|
+
}
|
|
122
|
+
const bytes = await render(template, options);
|
|
123
|
+
const buffer = Buffer.from(bytes);
|
|
124
|
+
fs.writeFileSync(resolvedPath, buffer);
|
|
125
|
+
return `Rendered image (${width}x${height} ${format.toUpperCase()}, ${buffer.length} bytes) saved to ${resolvedPath}. Fonts: ${fontsInfo}`;
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
return `Error rendering image: ${err?.message || err}`;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
export const RenderImageTool = {
|
|
132
|
+
name: "Image Renderer (Takumi)",
|
|
133
|
+
definition: toolDefinition,
|
|
134
|
+
handler
|
|
135
|
+
};
|