autoclaw 1.3.5 → 1.3.7
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 +56 -3
- package/README.zh-CN.md +347 -294
- package/dist/agent.js +13 -1
- package/dist/index.js +75 -1
- package/dist/skills.js +278 -0
- package/dist/tools/index.js +4 -0
- 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 +7 -4
- 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 +83 -0
- package/skills/poster-maker/references/scenario-playbook.md +85 -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/agent.js
CHANGED
|
@@ -9,6 +9,7 @@ import { getToolDefinitions, executeToolHandler, listUnavailableTools } from './
|
|
|
9
9
|
import { withRetry } from './retry.js';
|
|
10
10
|
import { truncateOutput } from './truncate.js';
|
|
11
11
|
import { buildShellInfo, resolveShellType } from './shell.js';
|
|
12
|
+
import { buildSkillsManifest } from './skills.js';
|
|
12
13
|
const DEFAULT_MAX_STEPS = 25;
|
|
13
14
|
const TOOL_RESULT_TRIM_MARKER = 'older tool output trimmed';
|
|
14
15
|
// Canonical JSON: sorted object keys, so equivalent arguments from
|
|
@@ -73,6 +74,17 @@ System Information:
|
|
|
73
74
|
has('optimize_prompt') ? '- Creation: optimize_prompt — refine raw prompts for creative/complex tasks (recommended before creative work)' : null,
|
|
74
75
|
'- Utility: get_current_datetime — accurate system time for temporal reasoning'
|
|
75
76
|
].filter((line) => line !== null).join('\n');
|
|
77
|
+
// Skills ride along as one-line manifest entries; the body is only read
|
|
78
|
+
// (via read_file) when a task actually matches. Never break startup on
|
|
79
|
+
// a malformed skill directory.
|
|
80
|
+
let skillsManifest = null;
|
|
81
|
+
try {
|
|
82
|
+
skillsManifest = buildSkillsManifest(config);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
skillsManifest = null;
|
|
86
|
+
}
|
|
87
|
+
const skillsBlock = skillsManifest ? `\n${skillsManifest}\n` : '';
|
|
76
88
|
this.messages = [
|
|
77
89
|
{
|
|
78
90
|
role: "system",
|
|
@@ -84,7 +96,7 @@ ${systemInfo}
|
|
|
84
96
|
|
|
85
97
|
WHAT YOU CAN DO:
|
|
86
98
|
${capabilities}
|
|
87
|
-
|
|
99
|
+
${skillsBlock}
|
|
88
100
|
RULES OF ENGAGEMENT:
|
|
89
101
|
1. One shot, not one chat. Produce working results, not conversation. Be terse.
|
|
90
102
|
2. Use the right tool for the job. Shell for system ops. Files for content. Web tools for external info.
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { parseManifest, runBatch } from './batch.js';
|
|
|
8
8
|
import { PROVIDER_PRESETS, providerNames, resolveProvider } from './providers.js';
|
|
9
9
|
import { fetchModelIds, normalizeBaseUrl, testConnection } from './setup.js';
|
|
10
10
|
import { collectDoctorChecks } from './doctor.js';
|
|
11
|
+
import { defaultSkillScopes, discoverSkills, installSkill, installSkillFromUrl, packSkill, removeSkill } from './skills.js';
|
|
11
12
|
import * as fs from 'fs';
|
|
12
13
|
import * as path from 'path';
|
|
13
14
|
import * as os from 'os';
|
|
@@ -46,7 +47,7 @@ dotenv.config({ path: GLOBAL_ENV_FILE });
|
|
|
46
47
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
47
48
|
// In dist/index.js, package.json is usually up one level in the root
|
|
48
49
|
const pkgPath = path.join(__dirname, '..', 'package.json');
|
|
49
|
-
let version = '1.3.
|
|
50
|
+
let version = '1.3.6';
|
|
50
51
|
try {
|
|
51
52
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
52
53
|
version = pkg.version;
|
|
@@ -90,6 +91,79 @@ program
|
|
|
90
91
|
const options = program.opts();
|
|
91
92
|
await runBatchCommand(manifest, options, cmdOptions);
|
|
92
93
|
});
|
|
94
|
+
const skillCmd = program
|
|
95
|
+
.command('skill')
|
|
96
|
+
.description('Manage skill packages (SKILL.md format, WorkBuddy-store compatible)');
|
|
97
|
+
skillCmd
|
|
98
|
+
.command('list')
|
|
99
|
+
.description('List discovered skills across builtin / user / project scopes')
|
|
100
|
+
.action(() => {
|
|
101
|
+
const { skills, warnings } = discoverSkills(defaultSkillScopes());
|
|
102
|
+
for (const w of warnings)
|
|
103
|
+
console.log(chalk.dim(` ! ${w}`));
|
|
104
|
+
if (skills.length === 0) {
|
|
105
|
+
console.log('No skills found. Install one with: autoclaw skill install <zip-or-dir>');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
console.log(chalk.bold.cyan(`Skills (${skills.length}):\n`));
|
|
109
|
+
for (const s of skills) {
|
|
110
|
+
const desc = s.descriptionZh || s.descriptionEn || s.description;
|
|
111
|
+
console.log(` ${chalk.bold(s.name)}${s.version ? chalk.dim(` v${s.version}`) : ''} ${chalk.dim(`[${s.source}]`)}`);
|
|
112
|
+
console.log(` ${desc.replace(/\s+/g, ' ').slice(0, 100)}${desc.length > 100 ? '…' : ''}`);
|
|
113
|
+
console.log(` ${chalk.dim(s.dir)}`);
|
|
114
|
+
}
|
|
115
|
+
console.log(chalk.dim('\nSkills run inside chat/batch automatically: the agent sees a skill list and reads a matched SKILL.md on demand.'));
|
|
116
|
+
});
|
|
117
|
+
skillCmd
|
|
118
|
+
.command('install <target>')
|
|
119
|
+
.description('Install a skill from an https .zip URL, a local .zip, or a directory (into ~/.autoclaw/skills/)')
|
|
120
|
+
.action(async (target) => {
|
|
121
|
+
try {
|
|
122
|
+
const result = /^https:\/\//.test(target)
|
|
123
|
+
? await installSkillFromUrl(target)
|
|
124
|
+
: installSkill(target);
|
|
125
|
+
console.log(chalk.green(`Installed skill '${result.name}' (${result.files} files) -> ${result.dir}`));
|
|
126
|
+
console.log(chalk.dim('Verify with: autoclaw skill list'));
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
console.error(chalk.red(`Error: ${err?.message || err}`));
|
|
130
|
+
process.exitCode = 1;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
skillCmd
|
|
134
|
+
.command('remove <name>')
|
|
135
|
+
.description('Remove a user-installed skill (~/.autoclaw/skills/)')
|
|
136
|
+
.action((name) => {
|
|
137
|
+
const result = removeSkill(name);
|
|
138
|
+
if (result === 'removed')
|
|
139
|
+
console.log(chalk.green(`Removed skill '${name}'.`));
|
|
140
|
+
else if (result === 'builtin') {
|
|
141
|
+
console.error(chalk.red(`'${name}' is a built-in skill and cannot be removed.`));
|
|
142
|
+
process.exitCode = 1;
|
|
143
|
+
}
|
|
144
|
+
else if (result === 'project') {
|
|
145
|
+
console.error(chalk.red(`'${name}' lives in .autoclaw/skills/ — delete it manually.`));
|
|
146
|
+
process.exitCode = 1;
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
console.error(chalk.red(`Skill '${name}' not found.`));
|
|
150
|
+
process.exitCode = 1;
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
skillCmd
|
|
154
|
+
.command('pack <dir>')
|
|
155
|
+
.description('Package a skill directory into a store-upload zip (skills/<name>/ at zip root)')
|
|
156
|
+
.option('-o, --output <file>', 'Output zip path (default: <name>-skill.zip in cwd)')
|
|
157
|
+
.action((dir, cmdOptions) => {
|
|
158
|
+
try {
|
|
159
|
+
const result = packSkill(dir, cmdOptions.output);
|
|
160
|
+
console.log(chalk.green(`Packed '${result.name}' (${result.fileCount} files) -> ${result.zipPath}`));
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
console.error(chalk.red(`Error: ${err?.message || err}`));
|
|
164
|
+
process.exitCode = 1;
|
|
165
|
+
}
|
|
166
|
+
});
|
|
93
167
|
program
|
|
94
168
|
.command('doctor')
|
|
95
169
|
.description('Diagnose configuration and environment (headless)')
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
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, 400)} [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: SKILL.md sits at the zip 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 (!isSafeSkillName(name))
|
|
258
|
+
throw new Error(`unsafe skill name: ${name}`);
|
|
259
|
+
const version = parsed?.frontmatter.version;
|
|
260
|
+
const versionTag = version ? version.replace(/[^A-Za-z0-9._-]/g, '-') : undefined;
|
|
261
|
+
const files = [];
|
|
262
|
+
const walk = (current, rel) => {
|
|
263
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
264
|
+
if (INSTALL_SKIP.has(entry.name) || entry.name === '.DS_Store')
|
|
265
|
+
continue;
|
|
266
|
+
const child = path.join(current, entry.name);
|
|
267
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
268
|
+
if (entry.isDirectory())
|
|
269
|
+
walk(child, childRel);
|
|
270
|
+
else if (entry.isFile())
|
|
271
|
+
files.push({ path: childRel, data: fs.readFileSync(child) });
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
walk(abs, '');
|
|
275
|
+
const zipPath = outPath || path.resolve(process.cwd(), versionTag ? `${name}-skill-${versionTag}.zip` : `${name}-skill.zip`);
|
|
276
|
+
fs.writeFileSync(zipPath, createZip(files));
|
|
277
|
+
return { zipPath, fileCount: files.length, name, version };
|
|
278
|
+
}
|
package/dist/tools/index.js
CHANGED
|
@@ -6,6 +6,8 @@ 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';
|
|
9
11
|
import { CheckBackgroundProcessTool, StartBackgroundProcessTool, StopBackgroundProcessTool } from './background.js';
|
|
10
12
|
// Central Registry of all available tools
|
|
11
13
|
export const toolRegistry = [
|
|
@@ -20,6 +22,8 @@ export const toolRegistry = [
|
|
|
20
22
|
BrowserTool,
|
|
21
23
|
ScreenshotTool,
|
|
22
24
|
ImageTool,
|
|
25
|
+
RenderImageTool,
|
|
26
|
+
RenderPdfTool,
|
|
23
27
|
StartBackgroundProcessTool,
|
|
24
28
|
CheckBackgroundProcessTool,
|
|
25
29
|
StopBackgroundProcessTool
|
|
@@ -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
|
+
};
|