lazyclaw 3.88.0 → 3.99.4

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.
@@ -0,0 +1,239 @@
1
+ // Remote skill installer.
2
+ //
3
+ // Resolves an OpenClaw-style spec into a set of locally-installed
4
+ // skills:
5
+ //
6
+ // lazyclaw skills install <user>/<repo> — main branch
7
+ // lazyclaw skills install <user>/<repo>@<ref> — branch / tag / sha
8
+ // lazyclaw skills install <user>/<repo>@<ref>:<path>
9
+ // — only files under <path>
10
+ //
11
+ // The "registry" is just GitHub. `lazyclaw skills install
12
+ // anthropic-skills/code-review@v1.2` fetches the tarball at
13
+ // https://codeload.github.com/anthropic-skills/code-review/tar.gz/v1.2
14
+ // and installs every `.md` it finds at the repo root and under
15
+ // `skills/` (or under the explicit subpath after the colon).
16
+ //
17
+ // Why GitHub directly instead of a hosted ClawHub: zero new
18
+ // infrastructure, the public-pasteable URL is what users already
19
+ // share, and tag pinning is reproducible.
20
+ //
21
+ // We deliberately do NOT auto-execute anything — skills are .md
22
+ // files whose content goes into the LLM's system prompt. No code
23
+ // runs. The worst-case ingest is "the prompt makes the model
24
+ // behave oddly", which is recoverable by `lazyclaw skills remove`.
25
+
26
+ import fs from 'node:fs';
27
+ import os from 'node:os';
28
+ import path from 'node:path';
29
+ import { spawn } from 'node:child_process';
30
+ import { Readable } from 'node:stream';
31
+
32
+ const GITHUB_SPEC = /^([\w.-]+)\/([\w.-]+)(?:@([^:]+))?(?::(.+))?$/;
33
+ const SKILL_EXT = '.md';
34
+ const MAX_TARBALL_BYTES = 16 * 1024 * 1024; // 16 MiB
35
+ const FETCH_TIMEOUT_MS = 30_000;
36
+
37
+ export class SkillInstallError extends Error {
38
+ constructor(message, code) {
39
+ super(message);
40
+ this.name = 'SkillInstallError';
41
+ this.code = code || 'SKILL_INSTALL_ERR';
42
+ }
43
+ }
44
+
45
+ export function parseGithubSpec(spec) {
46
+ const m = String(spec || '').match(GITHUB_SPEC);
47
+ if (!m) return null;
48
+ const [, owner, repo, ref, subpath] = m;
49
+ return {
50
+ owner,
51
+ repo,
52
+ ref: ref || 'main',
53
+ subpath: subpath ? normaliseSubpath(subpath) : '',
54
+ };
55
+ }
56
+
57
+ function normaliseSubpath(p) {
58
+ // Refuse absolute paths and `..` components — the extracted
59
+ // archive is treated as untrusted and we never want to walk
60
+ // outside it.
61
+ const s = String(p || '').replace(/^\.?\//, '').replace(/\\/g, '/');
62
+ if (path.isAbsolute(s) || s.split('/').includes('..')) {
63
+ throw new SkillInstallError(`bad subpath "${p}"`, 'SKILL_BAD_SUBPATH');
64
+ }
65
+ return s;
66
+ }
67
+
68
+ export function tarballUrl({ owner, repo, ref }) {
69
+ return `https://codeload.github.com/${owner}/${repo}/tar.gz/${encodeURIComponent(ref)}`;
70
+ }
71
+
72
+ /**
73
+ * Download + extract a GitHub tarball into <tmpdir>/<random>/.
74
+ * Returns the absolute path of the extracted top-level directory
75
+ * (codeload puts everything under <repo>-<sha>/ so we follow that).
76
+ */
77
+ export async function fetchAndExtract(spec, opts = {}) {
78
+ const fetchFn = opts.fetch || globalThis.fetch;
79
+ if (!fetchFn) throw new SkillInstallError('no fetch implementation', 'SKILL_NO_FETCH');
80
+ const url = tarballUrl(spec);
81
+ const maxBytes = Number(opts.maxBytes) > 0 ? Number(opts.maxBytes) : MAX_TARBALL_BYTES;
82
+
83
+ const ac = new AbortController();
84
+ const timer = setTimeout(() => ac.abort(new Error(`timeout after ${FETCH_TIMEOUT_MS}ms`)), opts.timeoutMs || FETCH_TIMEOUT_MS);
85
+ let res;
86
+ try {
87
+ res = await fetchFn(url, {
88
+ headers: { 'user-agent': 'lazyclaw-skills/1.0' },
89
+ redirect: 'follow',
90
+ signal: ac.signal,
91
+ });
92
+ } finally {
93
+ clearTimeout(timer);
94
+ }
95
+ if (!res.ok) {
96
+ throw new SkillInstallError(`fetch ${url} → ${res.status}`, 'SKILL_FETCH_FAIL');
97
+ }
98
+
99
+ // Stream the tarball into a temp dir using the system `tar` binary.
100
+ // Cheaper than pulling a Node tar dependency, and `tar` is on PATH
101
+ // wherever lazyclaw runs (macOS / Linux / WSL / modern Windows).
102
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lazyclaw-skill-'));
103
+ const child = spawn('tar', ['-xz', '-C', tmp], {
104
+ stdio: ['pipe', 'inherit', 'pipe'],
105
+ });
106
+ let stderrBuf = '';
107
+ child.stderr.on('data', (chunk) => { stderrBuf += chunk; });
108
+
109
+ // Cap how much we'll feed into tar so a malicious upstream can't
110
+ // exhaust disk. Counts the gzipped bytes; uncompressed could be
111
+ // 10× larger but skill bundles are typically << 1 MB compressed.
112
+ let total = 0;
113
+ const exited = new Promise((resolve, reject) => {
114
+ child.on('error', reject);
115
+ child.on('close', (code) => {
116
+ if (code !== 0) reject(new SkillInstallError(`tar exited ${code}: ${stderrBuf.slice(0, 300)}`, 'SKILL_TAR_FAIL'));
117
+ else resolve();
118
+ });
119
+ });
120
+
121
+ // Convert the WHATWG ReadableStream from `fetch` to a Node Readable
122
+ // and pipe to tar. Node 18+ exposes the conversion natively.
123
+ const nodeStream = res.body && typeof res.body.getReader === 'function'
124
+ ? Readable.fromWeb(res.body)
125
+ : res.body;
126
+ if (!nodeStream || typeof nodeStream.on !== 'function') {
127
+ child.stdin.end();
128
+ await exited.catch(() => {});
129
+ fs.rmSync(tmp, { recursive: true, force: true });
130
+ throw new SkillInstallError('tarball body is not a stream', 'SKILL_BAD_BODY');
131
+ }
132
+ await new Promise((resolve, reject) => {
133
+ nodeStream.on('data', (chunk) => {
134
+ total += chunk.length;
135
+ if (total > maxBytes) {
136
+ nodeStream.destroy(new SkillInstallError(`tarball exceeds ${maxBytes} bytes (override with --max-bytes)`, 'SKILL_TOO_BIG'));
137
+ return;
138
+ }
139
+ if (!child.stdin.write(chunk)) nodeStream.pause();
140
+ });
141
+ child.stdin.on('drain', () => nodeStream.resume());
142
+ nodeStream.on('error', reject);
143
+ nodeStream.on('end', () => { child.stdin.end(); resolve(); });
144
+ });
145
+ await exited;
146
+
147
+ // codeload tarballs always have a single top-level dir named
148
+ // <repo>-<sha-or-ref>/. Find it.
149
+ const entries = fs.readdirSync(tmp);
150
+ const top = entries.find((n) => fs.statSync(path.join(tmp, n)).isDirectory());
151
+ if (!top) {
152
+ fs.rmSync(tmp, { recursive: true, force: true });
153
+ throw new SkillInstallError('extracted archive is empty', 'SKILL_EMPTY');
154
+ }
155
+ return { tmpRoot: tmp, extracted: path.join(tmp, top) };
156
+ }
157
+
158
+ /**
159
+ * Walk an extracted repo and pick the .md files that look like
160
+ * skills. Heuristic:
161
+ * - if a `skills/` directory exists at the root, only files
162
+ * under there count
163
+ * - else, .md files at the repo root only (one level deep)
164
+ * - if `subpath` is set in the spec, that wins absolutely —
165
+ * all .md under spec.subpath are eligible
166
+ */
167
+ export function pickSkillFiles(extractedRoot, subpath = '') {
168
+ const root = subpath ? path.join(extractedRoot, subpath) : extractedRoot;
169
+ if (!fs.existsSync(root)) return [];
170
+ if (subpath) return collectMd(root, root, /* recurse */ true);
171
+ const skillsDir = path.join(extractedRoot, 'skills');
172
+ if (fs.existsSync(skillsDir)) return collectMd(skillsDir, skillsDir, true);
173
+ // Fallback: top-level only — README is the only meaningful .md
174
+ // most repos ship at the root, and that usually IS the skill.
175
+ return collectMd(extractedRoot, extractedRoot, false);
176
+ }
177
+
178
+ function collectMd(dir, baseRoot, recurse) {
179
+ const out = [];
180
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
181
+ const full = path.join(dir, entry.name);
182
+ if (entry.isDirectory()) {
183
+ if (recurse) out.push(...collectMd(full, baseRoot, true));
184
+ continue;
185
+ }
186
+ if (!entry.isFile()) continue;
187
+ if (!entry.name.toLowerCase().endsWith(SKILL_EXT)) continue;
188
+ out.push({ relative: path.relative(baseRoot, full), abs: full });
189
+ }
190
+ return out.sort((a, b) => a.relative.localeCompare(b.relative));
191
+ }
192
+
193
+ /**
194
+ * Install every picked skill into <configDir>/skills/<name>.md.
195
+ * Default name: file basename without .md, lower-cased, slashes
196
+ * replaced with `-`. `--prefix foo/` prepends `foo-` to every
197
+ * installed name so a multi-skill repo doesn't clobber adjacent
198
+ * locally-managed skills.
199
+ *
200
+ * Skips files that already exist unless `force` is true.
201
+ */
202
+ export function installPickedSkills(picked, configDir, opts = {}) {
203
+ const skillsRoot = path.join(configDir, 'skills');
204
+ fs.mkdirSync(skillsRoot, { recursive: true });
205
+ const installed = [];
206
+ const skipped = [];
207
+ for (const f of picked) {
208
+ const base = path.basename(f.relative, SKILL_EXT).toLowerCase();
209
+ const safe = base.replace(/[^a-z0-9_.-]+/g, '-');
210
+ const name = (opts.prefix ? opts.prefix.replace(/[^a-z0-9_.-]+/g, '-') + '-' : '') + safe;
211
+ const dst = path.join(skillsRoot, name + SKILL_EXT);
212
+ if (fs.existsSync(dst) && !opts.force) {
213
+ skipped.push({ name, reason: 'exists', dst });
214
+ continue;
215
+ }
216
+ fs.copyFileSync(f.abs, dst);
217
+ installed.push({ name, src: f.relative, dst, bytes: fs.statSync(dst).size });
218
+ }
219
+ return { installed, skipped };
220
+ }
221
+
222
+ export async function installFromGithub(spec, configDir, opts = {}) {
223
+ const parsed = typeof spec === 'string' ? parseGithubSpec(spec) : spec;
224
+ if (!parsed) throw new SkillInstallError(`bad spec — expected user/repo[@ref][:path]`, 'SKILL_BAD_SPEC');
225
+ const { tmpRoot, extracted } = await fetchAndExtract(parsed, opts);
226
+ try {
227
+ const picked = pickSkillFiles(extracted, parsed.subpath);
228
+ if (!picked.length) {
229
+ throw new SkillInstallError(
230
+ `no .md skills found in ${parsed.owner}/${parsed.repo}@${parsed.ref}${parsed.subpath ? ':' + parsed.subpath : ''}`,
231
+ 'SKILL_NONE_FOUND'
232
+ );
233
+ }
234
+ const r = installPickedSkills(picked, configDir, opts);
235
+ return { spec: parsed, ...r };
236
+ } finally {
237
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
238
+ }
239
+ }
package/workspace.mjs ADDED
@@ -0,0 +1,158 @@
1
+ // Workspace — OpenClaw-parity convention for project-rooted system
2
+ // prompts. A workspace is a directory at
3
+ //
4
+ // ~/.lazyclaw/workspaces/<name>/
5
+ // ├─ AGENTS.md — what the assistant should DO
6
+ // ├─ SOUL.md — how the assistant should THINK / behave
7
+ // ├─ TOOLS.md — what tools / commands it can reach for
8
+ //
9
+ // `lazyclaw chat --workspace foo` (or `agent --workspace foo`) reads
10
+ // the three files and synthesises a single system prompt. Skill
11
+ // composition still works alongside — workspace lives at the head,
12
+ // then any --skill content. Missing files are skipped silently so
13
+ // a half-set-up workspace still works.
14
+ //
15
+ // Why three files instead of one giant SYSTEM.md: the OpenClaw
16
+ // convention separates concerns so reviewers / teammates can edit
17
+ // the "what" (AGENTS) without churning the "how" (SOUL) — and the
18
+ // TOOLS file commonly comes from a generator (read from
19
+ // `lazyclaw providers list` etc).
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+
24
+ const FILES = [
25
+ { name: 'AGENTS.md', heading: 'AGENTS — what to do' },
26
+ { name: 'SOUL.md', heading: 'SOUL — how to behave' },
27
+ { name: 'TOOLS.md', heading: 'TOOLS — what is available' },
28
+ ];
29
+
30
+ export function workspaceRoot(cfgDir) {
31
+ return path.join(cfgDir, 'workspaces');
32
+ }
33
+
34
+ export function workspaceDir(cfgDir, name) {
35
+ if (!name || !/^[A-Za-z0-9_.-]+$/.test(name)) {
36
+ throw new Error('workspace name must match [A-Za-z0-9_.-]+');
37
+ }
38
+ return path.join(workspaceRoot(cfgDir), name);
39
+ }
40
+
41
+ // List every workspace under ~/.lazyclaw/workspaces/. Returns
42
+ // metadata (which of the three files are present, total size) so
43
+ // `lazyclaw workspace list` can show the user at a glance which
44
+ // workspaces are populated vs scaffolded-but-empty.
45
+ export function listWorkspaces(cfgDir) {
46
+ const root = workspaceRoot(cfgDir);
47
+ if (!fs.existsSync(root)) return [];
48
+ const out = [];
49
+ for (const name of fs.readdirSync(root)) {
50
+ const dir = path.join(root, name);
51
+ let st;
52
+ try { st = fs.statSync(dir); } catch { continue; }
53
+ if (!st.isDirectory()) continue;
54
+ const files = {};
55
+ let totalBytes = 0;
56
+ for (const f of FILES) {
57
+ const p = path.join(dir, f.name);
58
+ try {
59
+ const fst = fs.statSync(p);
60
+ files[f.name] = { bytes: fst.size, mtimeMs: fst.mtimeMs };
61
+ totalBytes += fst.size;
62
+ } catch { /* missing — leave undefined */ }
63
+ }
64
+ out.push({ name, dir, files, totalBytes });
65
+ }
66
+ // Newest-modified first.
67
+ out.sort((a, b) => {
68
+ const ma = Math.max(0, ...Object.values(a.files).map((f) => f.mtimeMs));
69
+ const mb = Math.max(0, ...Object.values(b.files).map((f) => f.mtimeMs));
70
+ return mb - ma;
71
+ });
72
+ return out;
73
+ }
74
+
75
+ // Scaffold a fresh workspace. Each file gets a tiny stub the user
76
+ // can replace. We deliberately don't pre-populate from a template
77
+ // repo — the OpenClaw stubs are intentionally short so the user
78
+ // reads them before editing.
79
+ export function initWorkspace(cfgDir, name) {
80
+ const dir = workspaceDir(cfgDir, name);
81
+ if (fs.existsSync(dir)) throw new Error(`workspace "${name}" already exists`);
82
+ fs.mkdirSync(dir, { recursive: true });
83
+ const stubs = {
84
+ 'AGENTS.md':
85
+ `# Agents
86
+
87
+ What this assistant is asked to DO. Plain English.
88
+
89
+ - Primary goal: ...
90
+ - Daily routines: ...
91
+ - When stuck, escalate to: ...
92
+ `,
93
+ 'SOUL.md':
94
+ `# Soul
95
+
96
+ How the assistant should BEHAVE — voice, defaults, hard rules.
97
+
98
+ - Tone: ...
99
+ - Defaults: prefer concise answers; ask before destructive actions.
100
+ - Never: hand-wave, fabricate citations, or skip running tests.
101
+ `,
102
+ 'TOOLS.md':
103
+ `# Tools
104
+
105
+ What the assistant can reach for, and how to invoke each one.
106
+
107
+ - \`lazyclaw browse <url>\` — fetch + markdown-ify a page
108
+ - \`lazyclaw message send <name> <text>\` — Slack / Discord webhook
109
+ - \`lazyclaw agent ...\` — one-shot LLM call
110
+
111
+ Add project-specific tools below.
112
+ `,
113
+ };
114
+ for (const [name, body] of Object.entries(stubs)) {
115
+ fs.writeFileSync(path.join(dir, name), body, 'utf8');
116
+ }
117
+ return dir;
118
+ }
119
+
120
+ // Compose the three files into a single system prompt. Returns ''
121
+ // when the workspace is empty (caller falls back to whatever it had
122
+ // before). Skip-on-missing keeps the contract forgiving.
123
+ export function composeWorkspacePrompt(cfgDir, name) {
124
+ if (!name) return '';
125
+ const dir = workspaceDir(cfgDir, name);
126
+ if (!fs.existsSync(dir)) {
127
+ throw new Error(`workspace "${name}" not found at ${dir}`);
128
+ }
129
+ const blocks = [];
130
+ for (const f of FILES) {
131
+ const p = path.join(dir, f.name);
132
+ let body;
133
+ try { body = fs.readFileSync(p, 'utf8').trim(); } catch { continue; }
134
+ if (!body) continue;
135
+ blocks.push(`# ${f.heading}\n\n${body}`);
136
+ }
137
+ return blocks.join('\n\n---\n\n');
138
+ }
139
+
140
+ // Read just one file's content so the CLI can `workspace show`
141
+ // without printing all three.
142
+ export function readWorkspaceFile(cfgDir, name, fileName) {
143
+ const dir = workspaceDir(cfgDir, name);
144
+ const allowed = FILES.map((f) => f.name);
145
+ if (!allowed.includes(fileName)) {
146
+ throw new Error(`unknown file "${fileName}" — must be one of ${allowed.join(', ')}`);
147
+ }
148
+ const p = path.join(dir, fileName);
149
+ return fs.readFileSync(p, 'utf8');
150
+ }
151
+
152
+ export function removeWorkspace(cfgDir, name) {
153
+ const dir = workspaceDir(cfgDir, name);
154
+ if (!fs.existsSync(dir)) throw new Error(`workspace "${name}" not found`);
155
+ fs.rmSync(dir, { recursive: true, force: true });
156
+ }
157
+
158
+ export const WORKSPACE_FILES = FILES.map((f) => f.name);