lazyclaw 3.99.3 → 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.
- package/browse.mjs +0 -0
- package/config_features.mjs +241 -0
- package/cron.mjs +359 -0
- package/package.json +7 -1
- package/sandbox.mjs +127 -0
- package/skills_install.mjs +239 -0
- package/workspace.mjs +158 -0
package/browse.mjs
ADDED
|
Binary file
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Config-driven CLI surfaces — auth-profile rotation, pairing
|
|
2
|
+
// (sender allowlist), nodes (device registration), and outbound
|
|
3
|
+
// messaging webhooks. Each one is a thin record-keeper layered on
|
|
4
|
+
// top of the existing readConfig / writeConfig pair: the CLI never
|
|
5
|
+
// stores a separate file.
|
|
6
|
+
//
|
|
7
|
+
// All four were called out as OpenClaw parity gaps; we implement
|
|
8
|
+
// them as plain config keys here so the SAME `lazyclaw config get`
|
|
9
|
+
// flow keeps working and `lazyclaw export | jq` already covers
|
|
10
|
+
// backups without us writing a second exporter.
|
|
11
|
+
|
|
12
|
+
// Auth profiles ────────────────────────────────────────────────
|
|
13
|
+
//
|
|
14
|
+
// cfg.authProfiles[provider] = [{ key, label, addedAt }]
|
|
15
|
+
//
|
|
16
|
+
// Picked over a single `api-key` field so a user can keep multiple
|
|
17
|
+
// keys for the same provider (work / personal / spare) and rotate
|
|
18
|
+
// when one hits a rate limit. The rotation cursor lives in
|
|
19
|
+
// cfg.authActiveProfile[provider] = label so the choice persists
|
|
20
|
+
// across invocations.
|
|
21
|
+
|
|
22
|
+
export function authList(cfg, provider) {
|
|
23
|
+
const profiles = (cfg.authProfiles || {})[provider] || [];
|
|
24
|
+
return profiles.map((p) => ({
|
|
25
|
+
label: p.label,
|
|
26
|
+
addedAt: p.addedAt,
|
|
27
|
+
keyMasked: maskKey(p.key),
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function authAdd(cfg, provider, key, label) {
|
|
32
|
+
if (!provider) throw new Error('provider is required');
|
|
33
|
+
if (!key) throw new Error('key is required');
|
|
34
|
+
cfg.authProfiles = cfg.authProfiles || {};
|
|
35
|
+
cfg.authProfiles[provider] = cfg.authProfiles[provider] || [];
|
|
36
|
+
const lbl = (label || `profile-${cfg.authProfiles[provider].length + 1}`).trim();
|
|
37
|
+
if (cfg.authProfiles[provider].some((p) => p.label === lbl)) {
|
|
38
|
+
throw new Error(`profile "${lbl}" already exists for ${provider}`);
|
|
39
|
+
}
|
|
40
|
+
cfg.authProfiles[provider].push({ key, label: lbl, addedAt: new Date().toISOString() });
|
|
41
|
+
// First-added profile becomes active so the user gets working
|
|
42
|
+
// auth rotation without a separate `auth use` step.
|
|
43
|
+
cfg.authActiveProfile = cfg.authActiveProfile || {};
|
|
44
|
+
if (!cfg.authActiveProfile[provider]) cfg.authActiveProfile[provider] = lbl;
|
|
45
|
+
return lbl;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function authRemove(cfg, provider, label) {
|
|
49
|
+
const arr = (cfg.authProfiles || {})[provider] || [];
|
|
50
|
+
const idx = arr.findIndex((p) => p.label === label);
|
|
51
|
+
if (idx < 0) throw new Error(`no profile "${label}" for ${provider}`);
|
|
52
|
+
arr.splice(idx, 1);
|
|
53
|
+
if ((cfg.authActiveProfile || {})[provider] === label) {
|
|
54
|
+
cfg.authActiveProfile[provider] = arr[0]?.label || '';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function authUse(cfg, provider, label) {
|
|
59
|
+
const arr = (cfg.authProfiles || {})[provider] || [];
|
|
60
|
+
if (!arr.some((p) => p.label === label)) {
|
|
61
|
+
throw new Error(`no profile "${label}" for ${provider}`);
|
|
62
|
+
}
|
|
63
|
+
cfg.authActiveProfile = cfg.authActiveProfile || {};
|
|
64
|
+
cfg.authActiveProfile[provider] = label;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function authRotate(cfg, provider) {
|
|
68
|
+
const arr = (cfg.authProfiles || {})[provider] || [];
|
|
69
|
+
if (arr.length < 2) return null;
|
|
70
|
+
cfg.authActiveProfile = cfg.authActiveProfile || {};
|
|
71
|
+
const cur = cfg.authActiveProfile[provider];
|
|
72
|
+
const idx = arr.findIndex((p) => p.label === cur);
|
|
73
|
+
const next = arr[(idx + 1) % arr.length];
|
|
74
|
+
cfg.authActiveProfile[provider] = next.label;
|
|
75
|
+
return next.label;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Resolves the api-key the chat / agent flow should send. Falls
|
|
79
|
+
// back to the legacy single `api-key` field so existing configs
|
|
80
|
+
// keep working without a migration.
|
|
81
|
+
export function resolveApiKey(cfg, provider) {
|
|
82
|
+
const arr = (cfg.authProfiles || {})[provider] || [];
|
|
83
|
+
const active = (cfg.authActiveProfile || {})[provider];
|
|
84
|
+
const hit = arr.find((p) => p.label === active) || arr[0];
|
|
85
|
+
if (hit?.key) return hit.key;
|
|
86
|
+
return cfg['api-key'] || '';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function maskKey(key) {
|
|
90
|
+
if (!key) return '';
|
|
91
|
+
const s = String(key);
|
|
92
|
+
if (s.length <= 8) return '****' + s.slice(-2);
|
|
93
|
+
return s.slice(0, 4) + '…' + s.slice(-4);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Pairing (sender allowlist) ───────────────────────────────────
|
|
97
|
+
//
|
|
98
|
+
// cfg.pairing = [{ id, label, addedAt }]
|
|
99
|
+
//
|
|
100
|
+
// Sender ids are the opaque strings the messaging layer hands us
|
|
101
|
+
// (e.g. Slack member id, Discord user id, phone number for SMS
|
|
102
|
+
// bridges). Anything that isn't on the allowlist gets rejected by
|
|
103
|
+
// the inbound handler — same shape as openclaw `pairing approve`.
|
|
104
|
+
|
|
105
|
+
export function pairingList(cfg) {
|
|
106
|
+
return (cfg.pairing || []).slice();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function pairingAdd(cfg, id, label) {
|
|
110
|
+
if (!id) throw new Error('id is required');
|
|
111
|
+
cfg.pairing = cfg.pairing || [];
|
|
112
|
+
if (cfg.pairing.some((p) => p.id === id)) {
|
|
113
|
+
throw new Error(`id "${id}" already paired`);
|
|
114
|
+
}
|
|
115
|
+
cfg.pairing.push({ id, label: label || '', addedAt: new Date().toISOString() });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function pairingRemove(cfg, id) {
|
|
119
|
+
const arr = cfg.pairing || [];
|
|
120
|
+
const idx = arr.findIndex((p) => p.id === id);
|
|
121
|
+
if (idx < 0) throw new Error(`id "${id}" not found`);
|
|
122
|
+
arr.splice(idx, 1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function pairingHas(cfg, id) {
|
|
126
|
+
return (cfg.pairing || []).some((p) => p.id === id);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Nodes (device registration) ──────────────────────────────────
|
|
130
|
+
//
|
|
131
|
+
// cfg.nodes = [{ id, platform, label, registeredAt }]
|
|
132
|
+
//
|
|
133
|
+
// CLI side of `openclaw nodes` — the actual mobile companion apps
|
|
134
|
+
// aren't in scope here, but the registration table lets a future
|
|
135
|
+
// app (or just `curl`) authenticate against `lazyclaw daemon`.
|
|
136
|
+
// Platform is free-form ('macos' / 'ios' / 'android' / 'web' /
|
|
137
|
+
// 'cli') so we don't constrain future surfaces.
|
|
138
|
+
|
|
139
|
+
export function nodesList(cfg) {
|
|
140
|
+
return (cfg.nodes || []).slice();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function nodesRegister(cfg, id, platform = 'cli', label = '') {
|
|
144
|
+
if (!id) throw new Error('id is required');
|
|
145
|
+
cfg.nodes = cfg.nodes || [];
|
|
146
|
+
if (cfg.nodes.some((n) => n.id === id)) {
|
|
147
|
+
throw new Error(`node "${id}" already registered`);
|
|
148
|
+
}
|
|
149
|
+
cfg.nodes.push({
|
|
150
|
+
id,
|
|
151
|
+
platform: String(platform || 'cli').toLowerCase(),
|
|
152
|
+
label: label || '',
|
|
153
|
+
registeredAt: new Date().toISOString(),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function nodesRemove(cfg, id) {
|
|
158
|
+
const arr = cfg.nodes || [];
|
|
159
|
+
const idx = arr.findIndex((n) => n.id === id);
|
|
160
|
+
if (idx < 0) throw new Error(`node "${id}" not found`);
|
|
161
|
+
arr.splice(idx, 1);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Messaging — outbound webhooks ────────────────────────────────
|
|
165
|
+
//
|
|
166
|
+
// cfg.messaging.webhooks[name] = { kind: 'slack'|'discord', url }
|
|
167
|
+
//
|
|
168
|
+
// We deliberately store webhook URLs (not bot tokens) because that
|
|
169
|
+
// keeps the install footprint small — any user can paste a Slack
|
|
170
|
+
// "Incoming Webhook" URL and start sending without registering an
|
|
171
|
+
// app. Bot tokens can be added later as a separate `messaging.tokens`
|
|
172
|
+
// shape when we wire the bidirectional inbox.
|
|
173
|
+
|
|
174
|
+
const WEBHOOK_PATTERNS = {
|
|
175
|
+
slack: /^https?:\/\/hooks\.slack\.com\//i,
|
|
176
|
+
discord: /^https?:\/\/(?:discord(?:app)?\.com|canary\.discord\.com)\/api\/webhooks\//i,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
function detectKind(url) {
|
|
180
|
+
for (const [kind, re] of Object.entries(WEBHOOK_PATTERNS)) {
|
|
181
|
+
if (re.test(url)) return kind;
|
|
182
|
+
}
|
|
183
|
+
return 'generic';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function messageList(cfg) {
|
|
187
|
+
const map = (cfg.messaging || {}).webhooks || {};
|
|
188
|
+
return Object.entries(map).map(([name, v]) => ({
|
|
189
|
+
name,
|
|
190
|
+
kind: v.kind,
|
|
191
|
+
urlMasked: v.url ? v.url.slice(0, 32) + '…' + v.url.slice(-6) : '',
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function messageAdd(cfg, name, url, kindOverride) {
|
|
196
|
+
if (!name) throw new Error('name is required');
|
|
197
|
+
if (!url) throw new Error('url is required');
|
|
198
|
+
cfg.messaging = cfg.messaging || {};
|
|
199
|
+
cfg.messaging.webhooks = cfg.messaging.webhooks || {};
|
|
200
|
+
if (cfg.messaging.webhooks[name]) {
|
|
201
|
+
throw new Error(`webhook "${name}" already exists`);
|
|
202
|
+
}
|
|
203
|
+
cfg.messaging.webhooks[name] = {
|
|
204
|
+
kind: kindOverride || detectKind(url),
|
|
205
|
+
url,
|
|
206
|
+
addedAt: new Date().toISOString(),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function messageRemove(cfg, name) {
|
|
211
|
+
const map = (cfg.messaging || {}).webhooks || {};
|
|
212
|
+
if (!map[name]) throw new Error(`webhook "${name}" not found`);
|
|
213
|
+
delete map[name];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function messageSend(cfg, name, text, opts = {}) {
|
|
217
|
+
const map = (cfg.messaging || {}).webhooks || {};
|
|
218
|
+
const hook = map[name];
|
|
219
|
+
if (!hook) throw new Error(`webhook "${name}" not configured — add via \`lazyclaw message add\``);
|
|
220
|
+
const fetchFn = opts.fetch || globalThis.fetch;
|
|
221
|
+
if (!fetchFn) throw new Error('no fetch implementation');
|
|
222
|
+
|
|
223
|
+
// Slack and Discord both accept a JSON body but with different key
|
|
224
|
+
// shapes — Slack uses { text }, Discord uses { content }. The
|
|
225
|
+
// generic kind sends a plain JSON envelope so user-supplied
|
|
226
|
+
// endpoints can ingest whatever shape they like via { text }.
|
|
227
|
+
let body;
|
|
228
|
+
if (hook.kind === 'discord') body = JSON.stringify({ content: text });
|
|
229
|
+
else body = JSON.stringify({ text });
|
|
230
|
+
|
|
231
|
+
const res = await fetchFn(hook.url, {
|
|
232
|
+
method: 'POST',
|
|
233
|
+
headers: { 'content-type': 'application/json' },
|
|
234
|
+
body,
|
|
235
|
+
});
|
|
236
|
+
if (!res.ok) {
|
|
237
|
+
const errText = (await (res.text?.() || Promise.resolve(''))).slice(0, 300);
|
|
238
|
+
throw new Error(`webhook ${hook.kind} send failed: ${res.status} ${errText}`);
|
|
239
|
+
}
|
|
240
|
+
return { ok: true, kind: hook.kind, status: res.status };
|
|
241
|
+
}
|
package/cron.mjs
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// Cron — recurring `lazyclaw agent` runs.
|
|
2
|
+
//
|
|
3
|
+
// `lazyclaw cron add daily-summary "0 9 * * *" -- agent "Summarise
|
|
4
|
+
// today's TODOs"` schedules the agent invocation every weekday at
|
|
5
|
+
// 9 AM. On macOS we install a launchd plist; on Linux / WSL we
|
|
6
|
+
// append a crontab entry. Both backends carry a per-job marker so
|
|
7
|
+
// `cron list` / `cron remove` round-trip cleanly.
|
|
8
|
+
//
|
|
9
|
+
// Why this is built into the CLI:
|
|
10
|
+
// - Most "make this scheduled" recipes for AI agents devolve into
|
|
11
|
+
// "add a crontab entry that pipes through a wrapper" — that's
|
|
12
|
+
// what we generate here, but with the right env vars to land in
|
|
13
|
+
// the user's lazyclaw config and a stable id for removal.
|
|
14
|
+
// - launchd plists on macOS don't honor `crontab -e`, so a
|
|
15
|
+
// single-platform implementation would feel broken to half the
|
|
16
|
+
// user base.
|
|
17
|
+
//
|
|
18
|
+
// The job spec lives in `cfg.cron[<name>]` so it survives
|
|
19
|
+
// uninstall/reinstall of the OS-level scheduler — `cron sync`
|
|
20
|
+
// reconciles. Schedule strings use 5-field cron syntax everywhere
|
|
21
|
+
// (the launchd backend internally translates to plist
|
|
22
|
+
// StartCalendarInterval entries).
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import os from 'node:os';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
28
|
+
|
|
29
|
+
const MARKER_PREFIX = '# lazyclaw-cron:';
|
|
30
|
+
|
|
31
|
+
class CronError extends Error {
|
|
32
|
+
constructor(message, code) { super(message); this.name = 'CronError'; this.code = code || 'CRON_ERR'; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 5-field cron spec parser — minimal but strict enough that
|
|
36
|
+
// "every Tuesday at 14:30" doesn't silently land at "every minute".
|
|
37
|
+
// Supports: number, *, range a-b, list a,b,c, step */n.
|
|
38
|
+
const FIELD_RANGES = [
|
|
39
|
+
{ name: 'minute', min: 0, max: 59 },
|
|
40
|
+
{ name: 'hour', min: 0, max: 23 },
|
|
41
|
+
{ name: 'dom', min: 1, max: 31 },
|
|
42
|
+
{ name: 'month', min: 1, max: 12 },
|
|
43
|
+
{ name: 'dow', min: 0, max: 6 },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export function parseCronSpec(spec) {
|
|
47
|
+
const tokens = String(spec || '').trim().split(/\s+/);
|
|
48
|
+
if (tokens.length !== 5) {
|
|
49
|
+
throw new CronError(`bad cron spec "${spec}" — need 5 fields, got ${tokens.length}`, 'CRON_BAD_SPEC');
|
|
50
|
+
}
|
|
51
|
+
const out = {};
|
|
52
|
+
for (let i = 0; i < 5; i++) {
|
|
53
|
+
const range = FIELD_RANGES[i];
|
|
54
|
+
out[range.name] = parseField(tokens[i], range);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseField(field, { name, min, max }) {
|
|
60
|
+
// Wildcard short-circuit.
|
|
61
|
+
if (field === '*') return { kind: 'any' };
|
|
62
|
+
// Step expressions: */N or RANGE/N.
|
|
63
|
+
const slash = field.indexOf('/');
|
|
64
|
+
if (slash >= 0) {
|
|
65
|
+
const head = field.slice(0, slash);
|
|
66
|
+
const step = Number(field.slice(slash + 1));
|
|
67
|
+
if (!Number.isFinite(step) || step < 1) {
|
|
68
|
+
throw new CronError(`bad step "${field}" in ${name}`, 'CRON_BAD_STEP');
|
|
69
|
+
}
|
|
70
|
+
if (head === '' || head === '*') return { kind: 'step', from: min, to: max, step };
|
|
71
|
+
const dash = head.indexOf('-');
|
|
72
|
+
if (dash < 0) throw new CronError(`bad step base "${head}" in ${name}`, 'CRON_BAD_STEP');
|
|
73
|
+
const a = Number(head.slice(0, dash)), b = Number(head.slice(dash + 1));
|
|
74
|
+
expectInRange(a, min, max, name); expectInRange(b, min, max, name);
|
|
75
|
+
return { kind: 'step', from: a, to: b, step };
|
|
76
|
+
}
|
|
77
|
+
// List a,b,c.
|
|
78
|
+
if (field.includes(',')) {
|
|
79
|
+
const items = field.split(',').map((p) => parseField(p, { name, min, max }));
|
|
80
|
+
return { kind: 'list', items };
|
|
81
|
+
}
|
|
82
|
+
// Range a-b.
|
|
83
|
+
const dash = field.indexOf('-');
|
|
84
|
+
if (dash >= 0) {
|
|
85
|
+
const a = Number(field.slice(0, dash)), b = Number(field.slice(dash + 1));
|
|
86
|
+
expectInRange(a, min, max, name); expectInRange(b, min, max, name);
|
|
87
|
+
return { kind: 'range', from: a, to: b };
|
|
88
|
+
}
|
|
89
|
+
// Plain number.
|
|
90
|
+
const n = Number(field);
|
|
91
|
+
expectInRange(n, min, max, name);
|
|
92
|
+
return { kind: 'value', value: n };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function expectInRange(n, min, max, fieldName) {
|
|
96
|
+
if (!Number.isFinite(n) || n < min || n > max) {
|
|
97
|
+
throw new CronError(`${fieldName} value ${n} out of range ${min}–${max}`, 'CRON_OUT_OF_RANGE');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Expand a parsed field into the explicit list of values it
|
|
102
|
+
// matches. launchd needs explicit values, not patterns.
|
|
103
|
+
export function expandField(parsed, { min, max }) {
|
|
104
|
+
if (!parsed) return [];
|
|
105
|
+
switch (parsed.kind) {
|
|
106
|
+
case 'any': return null; // null === "every"
|
|
107
|
+
case 'value': return [parsed.value];
|
|
108
|
+
case 'range': return inclusive(parsed.from, parsed.to);
|
|
109
|
+
case 'step': {
|
|
110
|
+
const out = [];
|
|
111
|
+
for (let i = parsed.from; i <= parsed.to; i += parsed.step) out.push(i);
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
case 'list': {
|
|
115
|
+
const out = new Set();
|
|
116
|
+
for (const item of parsed.items) {
|
|
117
|
+
const xs = expandField(item, { min, max });
|
|
118
|
+
if (xs === null) return null;
|
|
119
|
+
xs.forEach((v) => out.add(v));
|
|
120
|
+
}
|
|
121
|
+
return [...out].sort((a, b) => a - b);
|
|
122
|
+
}
|
|
123
|
+
default: return [];
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function inclusive(a, b) {
|
|
128
|
+
const [from, to] = a <= b ? [a, b] : [b, a];
|
|
129
|
+
return Array.from({ length: to - from + 1 }, (_, i) => from + i);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── id / shape ──────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
const NAME_RE = /^[A-Za-z0-9_.-]+$/;
|
|
135
|
+
|
|
136
|
+
export function ensureValidName(name) {
|
|
137
|
+
if (!name || !NAME_RE.test(name)) {
|
|
138
|
+
throw new CronError(`name "${name}" must match ${NAME_RE}`, 'CRON_BAD_NAME');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function listJobs(cfg) {
|
|
143
|
+
return Object.entries(cfg.cron || {}).map(([name, j]) => ({
|
|
144
|
+
name,
|
|
145
|
+
schedule: j.schedule,
|
|
146
|
+
command: j.command,
|
|
147
|
+
addedAt: j.addedAt,
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function getJob(cfg, name) {
|
|
152
|
+
return (cfg.cron || {})[name] || null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function upsertJob(cfg, name, schedule, command) {
|
|
156
|
+
ensureValidName(name);
|
|
157
|
+
parseCronSpec(schedule); // throws on bad spec
|
|
158
|
+
if (!command || (Array.isArray(command) && !command.length)) {
|
|
159
|
+
throw new CronError('command is required', 'CRON_NO_COMMAND');
|
|
160
|
+
}
|
|
161
|
+
cfg.cron = cfg.cron || {};
|
|
162
|
+
const existed = !!cfg.cron[name];
|
|
163
|
+
cfg.cron[name] = {
|
|
164
|
+
schedule,
|
|
165
|
+
command: Array.isArray(command) ? command : [String(command)],
|
|
166
|
+
addedAt: existed ? cfg.cron[name].addedAt : new Date().toISOString(),
|
|
167
|
+
updatedAt: new Date().toISOString(),
|
|
168
|
+
};
|
|
169
|
+
return existed ? 'updated' : 'created';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function removeJob(cfg, name) {
|
|
173
|
+
if (!cfg.cron || !cfg.cron[name]) throw new CronError(`no job "${name}"`, 'CRON_NO_JOB');
|
|
174
|
+
delete cfg.cron[name];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── installer (system scheduler) ────────────────────────────────
|
|
178
|
+
|
|
179
|
+
export function pickBackend() {
|
|
180
|
+
if (process.platform === 'darwin') return 'launchd';
|
|
181
|
+
return 'crontab';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function plistPath(name) {
|
|
185
|
+
return path.join(os.homedir(), 'Library', 'LaunchAgents', `com.lazyclaw.${name}.plist`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function buildPlist(name, schedule, command) {
|
|
189
|
+
const parsed = parseCronSpec(schedule);
|
|
190
|
+
const min = expandField(parsed.minute, { min: 0, max: 59 });
|
|
191
|
+
const hour = expandField(parsed.hour, { min: 0, max: 23 });
|
|
192
|
+
const dom = expandField(parsed.dom, { min: 1, max: 31 });
|
|
193
|
+
const month = expandField(parsed.month, { min: 1, max: 12 });
|
|
194
|
+
const dow = expandField(parsed.dow, { min: 0, max: 6 });
|
|
195
|
+
// launchd takes a single dict per fire-time. We expand the cron
|
|
196
|
+
// schedule into the cartesian product of (Minute × Hour × Day ×
|
|
197
|
+
// Month × Weekday); each null field means "every" so we encode
|
|
198
|
+
// nothing for it. For most schedules this is small (e.g. "every
|
|
199
|
+
// weekday 9 AM" = 5 entries).
|
|
200
|
+
const entries = cartesian([
|
|
201
|
+
minOrNull(min), minOrNull(hour), minOrNull(dom), minOrNull(month), minOrNull(dow),
|
|
202
|
+
]);
|
|
203
|
+
const intervals = entries.map(([Minute, Hour, Day, Month, Weekday]) => {
|
|
204
|
+
const dict = {};
|
|
205
|
+
if (Minute !== null) dict.Minute = Minute;
|
|
206
|
+
if (Hour !== null) dict.Hour = Hour;
|
|
207
|
+
if (Day !== null) dict.Day = Day;
|
|
208
|
+
if (Month !== null) dict.Month = Month;
|
|
209
|
+
if (Weekday !== null) dict.Weekday = Weekday;
|
|
210
|
+
return dict;
|
|
211
|
+
});
|
|
212
|
+
const programArguments = command;
|
|
213
|
+
const stdoutPath = path.join(os.homedir(), '.lazyclaw', 'logs', `cron-${name}.out.log`);
|
|
214
|
+
const stderrPath = path.join(os.homedir(), '.lazyclaw', 'logs', `cron-${name}.err.log`);
|
|
215
|
+
return renderPlist({
|
|
216
|
+
label: `com.lazyclaw.${name}`,
|
|
217
|
+
programArguments,
|
|
218
|
+
intervals,
|
|
219
|
+
stdoutPath,
|
|
220
|
+
stderrPath,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function minOrNull(arr) {
|
|
225
|
+
return arr === null ? [null] : arr;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function cartesian(arrs) {
|
|
229
|
+
return arrs.reduce((acc, arr) => acc.flatMap((row) => arr.map((v) => row.concat([v]))), [[]]);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function renderPlist({ label, programArguments, intervals, stdoutPath, stderrPath }) {
|
|
233
|
+
const argLines = programArguments.map((a) => ` <string>${escapeXml(a)}</string>`).join('\n');
|
|
234
|
+
const intervalDicts = intervals.map((i) => {
|
|
235
|
+
const inner = Object.entries(i)
|
|
236
|
+
.map(([k, v]) => ` <key>${k}</key>\n <integer>${v}</integer>`).join('\n');
|
|
237
|
+
return ` <dict>\n${inner}\n </dict>`;
|
|
238
|
+
}).join('\n');
|
|
239
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
240
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
241
|
+
<plist version="1.0">
|
|
242
|
+
<dict>
|
|
243
|
+
<key>Label</key>
|
|
244
|
+
<string>${escapeXml(label)}</string>
|
|
245
|
+
<key>ProgramArguments</key>
|
|
246
|
+
<array>
|
|
247
|
+
${argLines}
|
|
248
|
+
</array>
|
|
249
|
+
<key>StartCalendarInterval</key>
|
|
250
|
+
<array>
|
|
251
|
+
${intervalDicts}
|
|
252
|
+
</array>
|
|
253
|
+
<key>StandardOutPath</key>
|
|
254
|
+
<string>${escapeXml(stdoutPath)}</string>
|
|
255
|
+
<key>StandardErrorPath</key>
|
|
256
|
+
<string>${escapeXml(stderrPath)}</string>
|
|
257
|
+
<key>RunAtLoad</key>
|
|
258
|
+
<false/>
|
|
259
|
+
</dict>
|
|
260
|
+
</plist>
|
|
261
|
+
`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function escapeXml(s) {
|
|
265
|
+
return String(s)
|
|
266
|
+
.replace(/&/g, '&')
|
|
267
|
+
.replace(/</g, '<')
|
|
268
|
+
.replace(/>/g, '>')
|
|
269
|
+
.replace(/"/g, '"')
|
|
270
|
+
.replace(/'/g, ''');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── crontab backend (Linux / WSL) ───────────────────────────────
|
|
274
|
+
|
|
275
|
+
export function buildCrontabLine(name, schedule, command) {
|
|
276
|
+
const cmdStr = command.map(shellQuote).join(' ');
|
|
277
|
+
return `${schedule} ${cmdStr} ${MARKER_PREFIX}${name}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function shellQuote(arg) {
|
|
281
|
+
if (arg === '' || /[\s'"\\$`]/.test(arg)) return `'${String(arg).replace(/'/g, `'\\''`)}'`;
|
|
282
|
+
return String(arg);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Reads current crontab; ignores "no crontab" exit codes.
|
|
286
|
+
function readCrontab() {
|
|
287
|
+
const r = spawnSync('crontab', ['-l'], { encoding: 'utf8' });
|
|
288
|
+
if (r.status === 0) return r.stdout || '';
|
|
289
|
+
// exit 1 + stderr "no crontab" === empty crontab; treat as ''.
|
|
290
|
+
return '';
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function writeCrontab(text) {
|
|
294
|
+
const r = spawnSync('crontab', ['-'], { input: text, encoding: 'utf8' });
|
|
295
|
+
if (r.status !== 0) {
|
|
296
|
+
throw new CronError(`crontab write failed: ${r.stderr || r.status}`, 'CRON_WRITE_FAIL');
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function installCrontabJob(name, schedule, command) {
|
|
301
|
+
const line = buildCrontabLine(name, schedule, command);
|
|
302
|
+
const cur = readCrontab();
|
|
303
|
+
// Drop any prior line for the same name so update == replace.
|
|
304
|
+
const filtered = cur.split('\n').filter((ln) => !ln.endsWith(`${MARKER_PREFIX}${name}`));
|
|
305
|
+
const next = [...filtered, line].filter(Boolean).join('\n') + '\n';
|
|
306
|
+
writeCrontab(next);
|
|
307
|
+
return line;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function uninstallCrontabJob(name) {
|
|
311
|
+
const cur = readCrontab();
|
|
312
|
+
if (!cur) return false;
|
|
313
|
+
const next = cur.split('\n').filter((ln) => !ln.endsWith(`${MARKER_PREFIX}${name}`)).join('\n');
|
|
314
|
+
writeCrontab(next + (next.endsWith('\n') ? '' : '\n'));
|
|
315
|
+
return cur !== next + (next.endsWith('\n') ? '' : '\n');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── launchd backend (macOS) ─────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
export function installLaunchdJob(name, schedule, command) {
|
|
321
|
+
const text = buildPlist(name, schedule, command);
|
|
322
|
+
const dst = plistPath(name);
|
|
323
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
324
|
+
fs.mkdirSync(path.join(os.homedir(), '.lazyclaw', 'logs'), { recursive: true });
|
|
325
|
+
fs.writeFileSync(dst, text);
|
|
326
|
+
// Try to load the agent so it takes effect now. If launchctl
|
|
327
|
+
// refuses (already loaded, no GUI session), surface the error
|
|
328
|
+
// but leave the plist on disk — `launchctl load` later will
|
|
329
|
+
// pick it up.
|
|
330
|
+
spawnSync('launchctl', ['unload', dst], { stdio: 'ignore' });
|
|
331
|
+
const r = spawnSync('launchctl', ['load', dst], { encoding: 'utf8' });
|
|
332
|
+
if (r.status !== 0) {
|
|
333
|
+
throw new CronError(`launchctl load failed: ${r.stderr || r.status}`, 'CRON_LAUNCHD_FAIL');
|
|
334
|
+
}
|
|
335
|
+
return dst;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function uninstallLaunchdJob(name) {
|
|
339
|
+
const dst = plistPath(name);
|
|
340
|
+
if (fs.existsSync(dst)) spawnSync('launchctl', ['unload', dst], { stdio: 'ignore' });
|
|
341
|
+
if (fs.existsSync(dst)) fs.unlinkSync(dst);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ── cron run (one-shot, fired by the system) ────────────────────
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Synchronous run path that the OS scheduler invokes via the
|
|
348
|
+
* stored ProgramArguments. Reads the named job from the saved
|
|
349
|
+
* config, resolves the command to its argv, and exec()s it.
|
|
350
|
+
*/
|
|
351
|
+
export function runJob(cfg, name) {
|
|
352
|
+
const job = getJob(cfg, name);
|
|
353
|
+
if (!job) throw new CronError(`no job "${name}"`, 'CRON_NO_JOB');
|
|
354
|
+
const [bin, ...args] = job.command;
|
|
355
|
+
const r = spawnSync(bin, args, { stdio: 'inherit' });
|
|
356
|
+
return r.status ?? 0;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export { CronError };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lazyclaw",
|
|
3
|
-
"version": "3.99.
|
|
3
|
+
"version": "3.99.4",
|
|
4
4
|
"description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama and orchestrating multi-step LLM workflows. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -40,6 +40,12 @@
|
|
|
40
40
|
"ratelimit.mjs",
|
|
41
41
|
"config-validate.mjs",
|
|
42
42
|
"rates-validate.mjs",
|
|
43
|
+
"config_features.mjs",
|
|
44
|
+
"workspace.mjs",
|
|
45
|
+
"browse.mjs",
|
|
46
|
+
"sandbox.mjs",
|
|
47
|
+
"skills_install.mjs",
|
|
48
|
+
"cron.mjs",
|
|
43
49
|
"providers/",
|
|
44
50
|
"workflow/",
|
|
45
51
|
"web/",
|
package/sandbox.mjs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Sandbox — wrap a child process in a Docker container.
|
|
2
|
+
//
|
|
3
|
+
// `lazyclaw chat --sandbox docker:<image>` (or the equivalent on
|
|
4
|
+
// `agent`) routes the underlying `claude` CLI invocation through
|
|
5
|
+
//
|
|
6
|
+
// docker run --rm -i --network=<net> \
|
|
7
|
+
// -v <cwd>:<cwd> -w <cwd> \
|
|
8
|
+
// -e <pass-through env vars> \
|
|
9
|
+
// <image> claude -p ...
|
|
10
|
+
//
|
|
11
|
+
// instead of running `claude` directly on the host. Two reasons:
|
|
12
|
+
//
|
|
13
|
+
// 1. Filesystem confinement. The default --workdir mount only
|
|
14
|
+
// exposes the current working directory; tools that try to
|
|
15
|
+
// chdir into $HOME or read /etc see an empty container fs.
|
|
16
|
+
// 2. Network policy. By default we set --network=none so the
|
|
17
|
+
// sandboxed agent cannot reach the public internet — useful
|
|
18
|
+
// when handing it untrusted prompts. Pass `--network host` /
|
|
19
|
+
// `bridge` via flags when the workflow needs outbound access
|
|
20
|
+
// (e.g. it has to call an API).
|
|
21
|
+
//
|
|
22
|
+
// Caveats — call out so users aren't surprised:
|
|
23
|
+
//
|
|
24
|
+
// - The user's `claude` login lives in $HOME/.claude/. The sandbox
|
|
25
|
+
// doesn't expose $HOME by default, so the wrapped CLI can't see
|
|
26
|
+
// that auth and will prompt for login. To run sandboxed under
|
|
27
|
+
// the user's existing subscription, mount $HOME/.claude:
|
|
28
|
+
//
|
|
29
|
+
// lazyclaw chat --sandbox docker:node:20 \
|
|
30
|
+
// --sandbox-mount "$HOME/.claude:/root/.claude:ro"
|
|
31
|
+
//
|
|
32
|
+
// - Sandboxing only applies when the picked provider goes through
|
|
33
|
+
// a subprocess (currently `claude-cli`). API providers
|
|
34
|
+
// (anthropic / openai / gemini) hit the network from
|
|
35
|
+
// *lazyclaw's* process, not a child — sandboxing them is a
|
|
36
|
+
// no-op and we surface a warning.
|
|
37
|
+
|
|
38
|
+
import { spawn } from 'node:child_process';
|
|
39
|
+
|
|
40
|
+
class SandboxError extends Error {
|
|
41
|
+
constructor(message, code) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = 'SandboxError';
|
|
44
|
+
this.code = code || 'SANDBOX_ERR';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse a `--sandbox` flag. Accepts:
|
|
50
|
+
* docker:<image> — Docker with default policy
|
|
51
|
+
* docker:<image>?<args> — query-string-style overrides
|
|
52
|
+
* off | none | - — explicit "no sandbox"
|
|
53
|
+
*
|
|
54
|
+
* Returns null when sandboxing is off, or
|
|
55
|
+
* { kind: 'docker', image, network, mounts: string[], envPassthrough: string[] }.
|
|
56
|
+
*/
|
|
57
|
+
export function parseSandboxSpec(spec, flags = {}) {
|
|
58
|
+
if (!spec || /^(off|none|-)$/i.test(String(spec))) return null;
|
|
59
|
+
const m = String(spec).match(/^([a-z]+):(.+)$/i);
|
|
60
|
+
if (!m) throw new SandboxError(`bad sandbox spec "${spec}" — expected "docker:<image>"`, 'SANDBOX_BAD_SPEC');
|
|
61
|
+
const [, kind, rest] = m;
|
|
62
|
+
if (kind.toLowerCase() !== 'docker') {
|
|
63
|
+
throw new SandboxError(`unsupported sandbox kind "${kind}" — only "docker" is implemented`, 'SANDBOX_UNSUPPORTED');
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
kind: 'docker',
|
|
67
|
+
image: rest.trim(),
|
|
68
|
+
// Default to --network=none for safety. Override via:
|
|
69
|
+
// --sandbox-network host (or bridge / a named network)
|
|
70
|
+
network: flags['sandbox-network'] || 'none',
|
|
71
|
+
// --sandbox-mount can repeat; cli.parseArgs collects repeats
|
|
72
|
+
// into an array.
|
|
73
|
+
mounts: arrayify(flags['sandbox-mount']),
|
|
74
|
+
envPassthrough: arrayify(flags['sandbox-env']),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function arrayify(v) {
|
|
79
|
+
if (v === undefined || v === null) return [];
|
|
80
|
+
return Array.isArray(v) ? v : [String(v)];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the docker run argv that wraps a child invocation. The
|
|
85
|
+
* caller hands us the original [bin, ...args] they were going to
|
|
86
|
+
* spawn; we return [docker, ...dockerArgs] that puts the same
|
|
87
|
+
* thing inside the container.
|
|
88
|
+
*/
|
|
89
|
+
export function buildDockerArgs(spec, [bin, ...binArgs], opts = {}) {
|
|
90
|
+
if (!spec || spec.kind !== 'docker') {
|
|
91
|
+
throw new SandboxError('buildDockerArgs requires a docker spec', 'SANDBOX_BAD_SPEC');
|
|
92
|
+
}
|
|
93
|
+
const cwd = opts.cwd || process.cwd();
|
|
94
|
+
const args = [
|
|
95
|
+
'run', '--rm', '-i',
|
|
96
|
+
'--network', spec.network || 'none',
|
|
97
|
+
'-v', `${cwd}:${cwd}`,
|
|
98
|
+
'-w', cwd,
|
|
99
|
+
];
|
|
100
|
+
for (const mount of spec.mounts) {
|
|
101
|
+
if (!mount.includes(':')) {
|
|
102
|
+
throw new SandboxError(`bad mount "${mount}" — expected host:container[:mode]`, 'SANDBOX_BAD_MOUNT');
|
|
103
|
+
}
|
|
104
|
+
args.push('-v', mount);
|
|
105
|
+
}
|
|
106
|
+
for (const envName of spec.envPassthrough) {
|
|
107
|
+
args.push('-e', envName);
|
|
108
|
+
}
|
|
109
|
+
args.push(spec.image, bin, ...binArgs);
|
|
110
|
+
return args;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Spawn `bin` with `args` either bare (no sandbox) or under the
|
|
115
|
+
* docker wrapper. Returns the child process; the caller drives
|
|
116
|
+
* stdio and handles exit. Mirrors `child_process.spawn`'s shape.
|
|
117
|
+
*/
|
|
118
|
+
export function spawnSandboxed(spec, bin, args, spawnOpts = {}) {
|
|
119
|
+
if (!spec) return spawn(bin, args, spawnOpts);
|
|
120
|
+
if (spec.kind !== 'docker') {
|
|
121
|
+
throw new SandboxError(`unsupported kind "${spec.kind}"`, 'SANDBOX_UNSUPPORTED');
|
|
122
|
+
}
|
|
123
|
+
const dockerArgs = buildDockerArgs(spec, [bin, ...args], { cwd: spawnOpts.cwd });
|
|
124
|
+
return spawn('docker', dockerArgs, spawnOpts);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export { SandboxError };
|
|
@@ -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);
|